mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6e5c9990f | |||
| 1d7801b3c0 | |||
| c8a7068070 | |||
| bb04782e11 | |||
| 0a51c61f66 | |||
| 0af23c26b1 | |||
| 7ce106ed1b | |||
| 9f173193a7 | |||
| ec37f1c790 | |||
| 4a1ac47007 | |||
| 078d576383 | |||
| 79a2b9844e | |||
| f43ce3f368 | |||
| cc523181d2 | |||
| da7f980166 | |||
| 9c9f1841d6 | |||
| 3968adfcc4 | |||
| 08ab122adc | |||
| dc1f4c3492 | |||
| 936aa061c0 | |||
| 81698180dd | |||
| 933c7eb51c | |||
| 4b5086cdf1 | |||
| 90e069ee4b | |||
| 289072583e | |||
| 49df7393dc | |||
| 327dcb789c | |||
| 674a3fc257 | |||
| e93bbb14cb | |||
| 618bcd9203 | |||
| 6d78b7b115 | |||
| 6ee018b844 | |||
| 690e77f520 | |||
| e4126e38ce | |||
| a2ffb5ef4b | |||
| 1152d9ba48 | |||
| 5460cc3601 | |||
| 2c749c2677 | |||
| 2795d2ed7d | |||
| 757308b44d | |||
| 3d7d7ebdf1 | |||
| 5fcef3ffe7 | |||
| 28b5d5525b | |||
| 35ef784bd9 | |||
| 63e3b11736 | |||
| c472e7f342 | |||
| 1a946f3023 | |||
| e27b06ce5b | |||
| 479ea273ea | |||
| af0d7b6c20 | |||
| 4021f7c6e0 | |||
| 74951d5c30 | |||
| 892c27ffea | |||
| 0defa37928 | |||
| f23df2659d | |||
| 1417651c77 | |||
| a18236d064 | |||
| 3d2deeedf8 | |||
| 7b7eb651e3 | |||
| 8e87a9de82 | |||
| fc60c9bf40 | |||
| 9a1af3a99d | |||
| a8d7ab4944 | |||
| 111ee6a548 | |||
| 77e5ef5678 | |||
| 0986a0a118 | |||
| 628f430645 | |||
| fbfabaf26e | |||
| 7da20f2047 | |||
| 50e03af296 | |||
| a1a29bb64d | |||
| ac1dcf3ad7 | |||
| 273352db08 | |||
| 159ca2c271 | |||
| 5af3fa8fc5 | |||
| c9ed2df278 | |||
| 80f0691516 | |||
| 9d33b1f4d6 | |||
| 44fc9047e0 | |||
| 98fa7b89cf | |||
| ff55baa0c5 | |||
| 312ecbbdc8 | |||
| 1d4777e3da | |||
| 38fbd0faf1 | |||
| 99d3faec5b | |||
| 02f7bc386d | |||
| 27e5e0064c | |||
| 4034449a6d | |||
| 142f52569a | |||
| df73507ee9 | |||
| 70980bf3b6 | |||
| 157cc457a9 | |||
| fa7486915b | |||
| 1794d4e777 | |||
| 8426091e91 | |||
| fa7dac9ec1 | |||
| 67970e5d23 | |||
| fc5b116263 | |||
| e2ec948c74 | |||
| c6a4357c6f | |||
| 6704505dae | |||
| 269a600869 | |||
| b3a06ce5e8 | |||
| 1b0a9a62b4 | |||
| babfd2833f | |||
| 78ff4481db | |||
| dccefb0158 | |||
| 49cf91f77d | |||
| 46e2d879cd | |||
| 26d183af7d | |||
| 35459fdfdd | |||
| 94bad2912b | |||
| 44f53e80b8 | |||
| 44282f8f3b | |||
| bd4ceada65 | |||
| 86be2d004f | |||
| 3c4c2e660a | |||
| 08c091c203 | |||
| 5f523db485 |
@@ -0,0 +1,78 @@
|
||||
# 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
|
||||
@@ -11,6 +11,9 @@ on:
|
||||
tags:
|
||||
- 'v1-december-*'
|
||||
|
||||
permissions:
|
||||
contents: write # create the GitHub release + upload assets
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
|
||||
+7
-2
@@ -21,7 +21,7 @@ tmp/
|
||||
## OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.backups
|
||||
## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit)
|
||||
dump.cs
|
||||
il2cpp.h
|
||||
@@ -48,6 +48,7 @@ dist$ZIPNAME.zip
|
||||
## time someone rebuilds. Keep the source committed, regenerate output.
|
||||
DorkNet.Server/wwwroot/admin/index.html
|
||||
DorkNet.Server/wwwroot/admin/assets/
|
||||
DorkNet.Server/wwwroot/admin/brand/
|
||||
DorkNet.Server/wwwroot/admin/*.map
|
||||
DorkNet.Server/admin-ui/node_modules/
|
||||
DorkNet.Server/admin-ui/dist/
|
||||
@@ -58,6 +59,7 @@ DorkNet.Server/admin-ui/tsconfig.tsbuildinfo
|
||||
## Same reasoning as admin-ui above: hashed output, regenerate per build.
|
||||
DorkNet.Server/wwwroot/site/index.html
|
||||
DorkNet.Server/wwwroot/site/assets/
|
||||
DorkNet.Server/wwwroot/site/brand/
|
||||
DorkNet.Server/wwwroot/site/*.map
|
||||
DorkNet.Server/site/node_modules/
|
||||
DorkNet.Server/site/dist/
|
||||
@@ -83,4 +85,7 @@ Recovered/
|
||||
Recovered(*)/
|
||||
|
||||
|
||||
local-releases/**
|
||||
local-releases/**
|
||||
DorkNet.Models/bin-verify/**
|
||||
DorkNet.Models/bin-verify2/**
|
||||
identityserver-signing.pfx
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# context.
|
||||
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG NODE_VERSION=20
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
# ── admin SPA build stage ──────────────────────────────────────────
|
||||
# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
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\""]
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
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:
|
||||
|
||||
+294
-2123
@@ -3,9 +3,16 @@
|
||||
// server:
|
||||
//
|
||||
// 1. URI rewrite — `.rec.net` → user-configured host
|
||||
// 2. Photon AppId — override the baked-in Photon Cloud AppId
|
||||
// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth
|
||||
// 4. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
// 2. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
//
|
||||
// Plus the anti-cheat BYPASS patches (anti-tamper / ToxMod / file-hash)
|
||||
// needed so a modded client isn't kicked, and two opt-in features that
|
||||
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size).
|
||||
//
|
||||
// This is the STABLE, shipping mod: it carries no diagnostic/tracing
|
||||
// code. All of that lives in the separate DorkNet.DebugMod (drop it in
|
||||
// the Mods folder to debug); the dev-menu / dev-debug-tools tooling
|
||||
// lives outside the public repo entirely.
|
||||
//
|
||||
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
|
||||
// install-melon.ps1 wrapper writes that file on install; you can also
|
||||
@@ -22,13 +29,22 @@
|
||||
// Il2CppAssemblies directory just to write the source.
|
||||
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
@@ -39,15 +55,13 @@ public class Mod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||
internal static Mod? Instance;
|
||||
internal static string? DiagnosticsLogPath;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _networkPatchesRegistered;
|
||||
private bool _joinTracePatchesRegistered;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private int _diagnosticRetryFrame;
|
||||
private bool _firstUpdateLogged;
|
||||
private bool _antiTamperCallbackPatchesComplete;
|
||||
private int _antiTamperCallbackRetryFrame;
|
||||
private bool _toxModPatchesComplete;
|
||||
private int _toxModRetryFrame;
|
||||
|
||||
// Config backed by a plain JSON file under MelonLoader/UserData.
|
||||
// The defaults below match the recommended install, so dropping this
|
||||
@@ -55,38 +69,7 @@ public class Mod : MelonMod
|
||||
public static class Cfg
|
||||
{
|
||||
public static string ServerHost = "localhost";
|
||||
public static string PhotonAppId = "";
|
||||
public static string PhotonVoiceAppId = "";
|
||||
// Forced PhotonServerSettings.PreferredRegion (with HostType
|
||||
// switched to PhotonCloud) so two watches don't auto-ping
|
||||
// themselves into different "best" regions and end up in
|
||||
// parallel Photon rooms sharing only a name. PhotonPatches
|
||||
// .ApplyForcedRegion prefers the server-told region from
|
||||
// Matchmaking.LocalRoomInstance.PhotonRegionId when available
|
||||
// and falls back to this config value on initial boot before
|
||||
// any /goto has returned. Default matches the server's
|
||||
// appsettings.json Photon:CloudRegion ("eu").
|
||||
public static string PhotonCloudRegion = "eu";
|
||||
// Photon Custom Auth injector parked 2026-05-28; see
|
||||
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
|
||||
public static bool EnableTlsTrustBypass = true;
|
||||
// Log-only trace for the stuck first-dorm "Change Username?"
|
||||
// dialog. When true, RegistrationPatches hooks the dorm
|
||||
// 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
|
||||
// (normally dev-gated) and silence CheatManager's tamper detectors
|
||||
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
|
||||
// drop you to the dorm. Default OFF — purely opt-in. The toggle key
|
||||
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
|
||||
// ── Desktop Screen Sharing gadget FPS ──
|
||||
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
|
||||
@@ -111,29 +94,6 @@ public class Mod : MelonMod
|
||||
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
|
||||
@@ -150,53 +110,20 @@ public class Mod : MelonMod
|
||||
public static string[] QuestMaxTeamSizeRooms =
|
||||
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
|
||||
}
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet ClientMod loading ===");
|
||||
LoadConfig();
|
||||
Log.Msg($"DorkNet ClientMod loaded — host={Cfg.ServerHost}, tlsBypass={Cfg.EnableTlsTrustBypass}");
|
||||
RegisterNetworkPatches();
|
||||
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
|
||||
}
|
||||
|
||||
public override void OnLateInitializeMelon()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
|
||||
Log.Msg("=== Registering client patches ===");
|
||||
RegisterNetworkPatches();
|
||||
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
|
||||
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
|
||||
// Hook the watch's own AppSettings + AuthValues builders directly.
|
||||
// Both are private PUNNetworkManager methods with obfuscated 2020.12
|
||||
// names — we know they always run before any Photon connect because
|
||||
// ConnectUsingSettings reads their return values. Postfix logs them
|
||||
// after construction.
|
||||
//
|
||||
// FJOLIPKKIBE returns Photon.Realtime.AppSettings — verified at
|
||||
// PUNNetworkManager.txt:7046. One bool arg (DOJLKMCEFFB — likely
|
||||
// "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));
|
||||
TryPatchByName("RecRoom.AntiCheat.EACManager",
|
||||
"GenerateChallengeResponse",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(AntiCheatPatches.GenerateChallengeResponse_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
|
||||
@@ -214,145 +141,11 @@ public class Mod : MelonMod
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
}
|
||||
// Post-save blob-name refresh. The 2020 watch's
|
||||
// RoomPersistenceManager.RoomDataBlobName is only ever set by
|
||||
// /goto-driven flows — neither the SubscriptionUpdateRoom push
|
||||
// nor SubscriptionUpdatePresence pushes the server fires after
|
||||
// save propagate into this field. So the PostSaveReloading
|
||||
// sub-state's MasterReloadRoomAsync deserializes the cached
|
||||
// bytes from the OLD blob name (the one /goto returned at boot),
|
||||
// wiping any MakerPen object the user just placed even though
|
||||
// the new blob is already on S3.
|
||||
//
|
||||
// Verified live: after a dorm save with object placement, server
|
||||
// pushes SubscriptionUpdateRoom + PresenceUpdate both carrying
|
||||
// dataBlob="dorm_p<id>_v(N+1).dat", but the watch's
|
||||
// set_RoomDataBlobName trace immediately after the save shows
|
||||
// old="v(N)" new="v(N)" — no propagation, no fresh download,
|
||||
// no respawn. Leave+return triggers /goto which DOES update
|
||||
// RoomDataBlobName, so the fix is to do the same write
|
||||
// ourselves: when entering the SavingRoomSuccess state on the
|
||||
// RoomPersistenceManager, read the just-pushed
|
||||
// Rooms.LocalRoomScene.DataBlobName and force-set
|
||||
// __instance.RoomDataBlobName to it. That fires the real
|
||||
// setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync
|
||||
// for the new blob → PostSaveReloading picks up the fresh
|
||||
// bytes and respawns the MakerPen objects.
|
||||
TryPatchByName("RecRoom.Persistence.RoomPersistenceManager",
|
||||
"set_RoomDataBlobName",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix));
|
||||
// Intercept the SubscriptionUpdateRoom push BEFORE the watch's
|
||||
// (cachedRoomId, cachedSubRoomId) early-out gate drops it. We
|
||||
// stash the just-arrived Scenes[0].DataBlobName into a static
|
||||
// so the set_RoomDataBlobName hijack can use it — otherwise
|
||||
// LocalRoomDetails / LocalRoomScene never get the new blob
|
||||
// name on the second-or-later push for the same (room, sub)
|
||||
// tuple (= every post-save push in a dorm).
|
||||
TryPatchByName("RecNet.Rooms",
|
||||
"OnSubscriptionUpdateRoom",
|
||||
prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix));
|
||||
// Force AccountExtensions.CanLocalPlayerChat to return true. The
|
||||
// 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote
|
||||
// Coroutine d__53 offset 040) gates on this — if false, the
|
||||
// coroutine bails before raising the Photon RPC, so the sender
|
||||
// doesn't even see their own message. The check ANDs three
|
||||
// signals: LocalAccount.TreatAsJunior (ObscuredBool at offset
|
||||
// 0x40), a PlatformManager flag, and a ModerationManager
|
||||
// singleton field. At least one returns the wrong value against
|
||||
// our server. Force-allow — single-tenant private server, text
|
||||
// moderation isn't a concern.
|
||||
TryPatchByName("RecNet.AccountExtensions",
|
||||
"CanLocalPlayerChat",
|
||||
args: Type.EmptyTypes,
|
||||
postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix));
|
||||
// PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat
|
||||
// font asset's character table via TMP_FontAsset.HasCharacters
|
||||
// to strip glyphs the font can't render. On 2020 Rec Room the
|
||||
// chat font field can be null at the moment this fires (font
|
||||
// asset bundle hasn't loaded yet?) and HasCharacters NREs deep
|
||||
// inside Unity's TMP — which propagates out through the chat
|
||||
// send callback and aborts the RPC. The watch's own ISIL has
|
||||
// "skipping invalid character check" log paths for missing
|
||||
// assets but doesn't actually short-circuit on the null field
|
||||
// we hit. Skip the method entirely; chat lines will just keep
|
||||
// any glyphs the font can't render (most common: emoji).
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RemoveInvalidCharactersFromMessage",
|
||||
prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix));
|
||||
// Trace hooks so we can see exactly where chat dies. Send path:
|
||||
// KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string)
|
||||
// → PlayerEmotes.SendChatEmote(msg, true)
|
||||
// → SendChatEmoteCoroutine d__53 → PurifyString HTTP call
|
||||
// → b__0 callback (error, cleanVersion) — fires Photon RPC
|
||||
// AND local-echoes via ProcessNewChatMessageReceived
|
||||
// → on each receiver (including self): RpcChatEmote(msg) →
|
||||
// ProcessNewChatMessageReceived → ReceiveRoomChat appends to
|
||||
// the chat log buffer.
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"SendChatEmote",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.SendChatEmote_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"ProcessNewChatMessageReceived",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RpcChatEmote",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
|
||||
|
||||
// Diagnostic trace for the stuck first-dorm "Change Username?"
|
||||
// dialog (new accounts can't get past it; "Okay" highlights but
|
||||
// never dismisses the modal, on every device). This is LOG-ONLY —
|
||||
// no behaviour change. It bisects the failure for a brand-new
|
||||
// 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",
|
||||
"IsFullyRegistered",
|
||||
args: Type.EmptyTypes,
|
||||
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();
|
||||
|
||||
// Anti-tamper / ToxMod / file-hash-checker are anti-cheat BYPASS
|
||||
// patches — core, always on, or the modded client gets kicked.
|
||||
RegisterAntiTamperCallbackPatches(logMisses: true);
|
||||
RegisterToxModPatches(logMisses: true);
|
||||
PatchFileHashCheckerCallback();
|
||||
// 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
|
||||
@@ -362,15 +155,8 @@ public class Mod : MelonMod
|
||||
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
|
||||
@@ -378,159 +164,151 @@ public class Mod : MelonMod
|
||||
if (Cfg.QuestMaxTeamSize > 0)
|
||||
{
|
||||
// The runtime team cap is built from the baked GameConfigurationAsset
|
||||
// (a readable ScriptableObject) by GameConfigurationAsset.GDECPANDBHJ()
|
||||
// (a readable ScriptableObject) by GameConfigurationAsset.FIFOMGAOOHH()
|
||||
// — 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",
|
||||
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "FIFOMGAOOHH",
|
||||
prefix: nameof(QuestTeamSize.Build_Prefix));
|
||||
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
|
||||
TryPatchByName("GameSpawnManager", "FKEICBPCMPJ",
|
||||
TryPatchByName("GameSpawnManager", "KMPAOJGOOCH",
|
||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
|
||||
}
|
||||
|
||||
RegisterDiagnostics();
|
||||
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()
|
||||
private bool PatchFileHashCheckerCallback()
|
||||
{
|
||||
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 prefixMethod = GetPatchMethod(nameof(FileHashCheckerPatches.InitializeCallback_Prefix));
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
if (method.Name != methodName) continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
|
||||
if (!MatchesFileHashCheckerCallbackType(type)) continue;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (!MatchesFileHashCheckerCallbackMethod(method)) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] file hash checker callback {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
|
||||
Log.Warning("[patch-miss] file hash checker callback");
|
||||
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)
|
||||
private void RegisterAntiTamperCallbackPatches(bool logMisses)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
|
||||
return false;
|
||||
}
|
||||
var complete = true;
|
||||
complete &= PatchCallbackMethod(
|
||||
"hile warning callback",
|
||||
type => TypeNameContains(type, "FPIBGPIAOBI") && TypeNameContains(type, "KHIIHOIFDMP"),
|
||||
method => MethodNameContains(method, "CreateHileWarning") && MethodNameContains(method, "b__0"),
|
||||
nameof(AntiTamperPatches.CreateHileWarningCallback_Prefix),
|
||||
logMisses);
|
||||
complete &= PatchCallbackMethod(
|
||||
"unknown DLL callback",
|
||||
type => TypeNameContains(type, "CheatManager") && TypeNameContains(type, "PNHDGEKCJCH"),
|
||||
method => MethodNameContains(method, "OnUnknownDLLDetected") && MethodNameContains(method, "b__0"),
|
||||
nameof(AntiTamperPatches.UnknownDllDetectedCallback_Prefix),
|
||||
logMisses);
|
||||
_antiTamperCallbackPatchesComplete = complete;
|
||||
}
|
||||
private void RegisterToxModPatches(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("ToxMod.ToxModVoiceComponent",
|
||||
"CanInitializeToxMod",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(ToxModPatches.CanInitializeToxMod_Prefix),
|
||||
logMiss: logMisses);
|
||||
_toxModPatchesComplete = complete;
|
||||
}
|
||||
private bool PatchCallbackMethod(string label,
|
||||
Func<Type, bool> typePredicate,
|
||||
Func<MethodInfo, bool> methodPredicate,
|
||||
string prefix,
|
||||
bool logMiss)
|
||||
{
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
var patched = 0;
|
||||
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(nested))
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
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
|
||||
if (!typePredicate(type)) continue;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
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 (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (!methodPredicate(method)) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {label} {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patched == 0 && logMiss)
|
||||
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
|
||||
return patched > 0;
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
private static bool TypeNameContains(Type type, string value)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains(value, StringComparison.Ordinal);
|
||||
}
|
||||
private static bool MethodNameContains(MethodInfo method, string value)
|
||||
=> method.Name.Contains(value, StringComparison.Ordinal);
|
||||
private static bool MatchesFileHashCheckerCallbackType(Type type)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains("BEPEAHOOLDB", StringComparison.Ordinal)
|
||||
&& fullName.Contains("PBENKLNHBHL", StringComparison.Ordinal);
|
||||
}
|
||||
private static bool MatchesFileHashCheckerCallbackMethod(MethodInfo method)
|
||||
{
|
||||
if (method.ReturnType != typeof(void)) return false;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length != 1) return false;
|
||||
|
||||
var name = method.Name;
|
||||
if (name == "<InitializeFileHashChecker>b__0") return true;
|
||||
if (name == "_InitializeFileHashChecker_b__0") return true;
|
||||
return name.Contains("InitializeFileHashChecker", StringComparison.Ordinal)
|
||||
&& name.Contains("b__0", StringComparison.Ordinal);
|
||||
}
|
||||
private void RegisterNetworkPatches()
|
||||
{
|
||||
if (_networkPatchesRegistered) return;
|
||||
@@ -551,7 +329,6 @@ public class Mod : MelonMod
|
||||
|
||||
_networkPatchesRegistered = complete;
|
||||
}
|
||||
|
||||
private bool TryPatchHttpManagerStringSendRequestOverloads()
|
||||
{
|
||||
var type = ResolveType("BestHTTP.HTTPManager");
|
||||
@@ -578,74 +355,48 @@ public class Mod : MelonMod
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (!_firstUpdateLogged)
|
||||
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
|
||||
{
|
||||
_firstUpdateLogged = true;
|
||||
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
|
||||
_antiTamperCallbackRetryFrame = 0;
|
||||
RegisterAntiTamperCallbackPatches(logMisses: false);
|
||||
}
|
||||
|
||||
if (_diagnosticGameComplete) return;
|
||||
if (_diagnosticRetryFrame++ > 3600) return;
|
||||
if ((_diagnosticRetryFrame % 60) != 0) return;
|
||||
|
||||
var logMisses = (_diagnosticRetryFrame % 300) == 0;
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses);
|
||||
if (_diagnosticGameComplete)
|
||||
DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered");
|
||||
if (!_toxModPatchesComplete && ++_toxModRetryFrame >= 300)
|
||||
{
|
||||
_toxModRetryFrame = 0;
|
||||
RegisterToxModPatches(logMisses: false);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnApplicationQuit");
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
private static void LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Compute the UserData path ourselves rather than calling
|
||||
// MelonLoader.MelonUtils.UserDataDirectory (which is renamed
|
||||
// to MelonEnvironment.UserDataDirectory in newer 0.6.x builds
|
||||
// and shifts again in 0.7.x). Anchoring off the mod DLL's
|
||||
// location keeps this stable across versions: the DLL lives
|
||||
// in <game>/Mods/, so two dirname()'s reach the game root,
|
||||
// then MelonLoader/UserData/ is right next to it.
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
|
||||
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
var userData = ResolveUserDataDirectory();
|
||||
Directory.CreateDirectory(userData);
|
||||
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
|
||||
|
||||
var path = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Msg($"[config] no file at {path}; using defaults (ServerHost={Cfg.ServerHost})");
|
||||
return;
|
||||
}
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||
if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
|
||||
if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
|
||||
if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
|
||||
if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||
if (r.TryGetProperty("TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
|
||||
if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (r.TryGetProperty("DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
|
||||
if (r.TryGetProperty("DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||
if (r.TryGetProperty("DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||
if (r.TryGetProperty("DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||
if (r.TryGetProperty("QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||
if (r.TryGetProperty("QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||
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, "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())
|
||||
@@ -653,29 +404,48 @@ public class Mod : MelonMod
|
||||
rooms.Add(e.GetString()!);
|
||||
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
|
||||
}
|
||||
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();
|
||||
}
|
||||
// "InjectAuthValues" key in the template is now ignored —
|
||||
// see attic/AuthValuesInjector.cs.attic.
|
||||
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
|
||||
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Patch dispatch helpers ────────────────────────────────────────
|
||||
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;
|
||||
}
|
||||
private bool TryPatch(string label, Type target, string methodName,
|
||||
Type[]? args, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
@@ -699,7 +469,6 @@ public class Mod : MelonMod
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {label}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -708,13 +477,6 @@ public class Mod : MelonMod
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the game-side type by its ORIGINAL name (no Il2Cpp prefix)
|
||||
// by walking every loaded assembly. Tries the exact name first, then
|
||||
// the same name with an `Il2Cpp` prefix, then a substring scan as a
|
||||
// last resort — covers all the IL2CPP interop naming schemes the user
|
||||
// might end up with depending on the MelonLoader / Il2CppInterop
|
||||
// version installed.
|
||||
private bool TryPatchByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
@@ -728,59 +490,6 @@ public class Mod : MelonMod
|
||||
}
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||
@@ -792,6 +501,8 @@ public class Mod : MelonMod
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return CacheResolvedType(name, t);
|
||||
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
|
||||
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return CacheResolvedType(name, prefixed);
|
||||
// For namespaced types like "RecNet.Core", also try with
|
||||
@@ -816,13 +527,11 @@ public class Mod : MelonMod
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Type CacheResolvedType(string name, Type type)
|
||||
{
|
||||
ResolvedTypeCache[name] = type;
|
||||
return type;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
|
||||
{
|
||||
try
|
||||
@@ -843,25 +552,16 @@ public class Mod : MelonMod
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesRequestedType(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed) return true;
|
||||
var dottedPrefixed = "Il2Cpp." + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) 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 ||
|
||||
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)
|
||||
{
|
||||
if (args is null) return $"{typeName}.{methodName}";
|
||||
@@ -870,74 +570,16 @@ public class Mod : MelonMod
|
||||
names.Add(arg.Name);
|
||||
return $"{typeName}.{methodName}({string.Join(",", names)})";
|
||||
}
|
||||
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
// Look in all three patch holder classes — small enough that a
|
||||
// linear scan is cheaper than per-class lookups.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||
// Look in the core + feature patch holder classes.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
}
|
||||
throw new MissingMethodException($"patch method '{name}' not found on any holder");
|
||||
}
|
||||
|
||||
private void RegisterDiagnostics()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostics ===");
|
||||
if (!_diagnosticCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
|
||||
_diagnosticCoreRegistered = true;
|
||||
}
|
||||
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
|
||||
Log.Msg("=== Diagnostics registered ===");
|
||||
}
|
||||
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
|
||||
// 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[]
|
||||
{
|
||||
"OnTimeCheatDetected",
|
||||
"OnObscuredTypeCheatDetected",
|
||||
"OnHeightCheatDetected",
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticByName("CheatManager", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
|
||||
// pure-logging diagnostics that fired during Unity's very first
|
||||
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
|
||||
// methods) MonoMod's lazy CompileMethodHook on the trampoline
|
||||
// crashes the CLR with 0x80131506 the instant Awake JITs. The
|
||||
// hooks gave us no behavioral leverage — only a `[call] …` log
|
||||
// line — so they're disabled. If anyone needs Steam-side visibility
|
||||
// later, route it through the game's own log lines instead.
|
||||
return complete;
|
||||
}
|
||||
|
||||
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
@@ -954,626 +596,6 @@ public class Mod : MelonMod
|
||||
}
|
||||
}
|
||||
|
||||
// ── Patch holders ─────────────────────────────────────────────────────
|
||||
// Each holder is a static class with public static Prefix methods that
|
||||
// Harmony invokes. Kept separate from Mod for readability and so the
|
||||
// reflection lookup in GetPatchMethod stays simple.
|
||||
|
||||
// ── Debug console access ───────────────────────────────────────────────
|
||||
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
|
||||
// dev console (text input + a DebugConsoleCommandConfig command table:
|
||||
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
|
||||
// …). Retail gates it behind a developer flag. This holder force-toggles
|
||||
// its UI on a hotkey and (via the OnLateInitializeMelon patch above) skips
|
||||
// CheatManager's tamper detectors so movement/time commands don't drop you
|
||||
// to the dorm.
|
||||
//
|
||||
// Everything is resolved by reflection at runtime so the SAME code works on
|
||||
// both client builds despite their differing obfuscation:
|
||||
// * The show/hide toggle is the one non-compiler-generated private
|
||||
// instance void(bool) on DebugConsole — ShowInputField on 2020.03,
|
||||
// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated
|
||||
// one, then fall back to that structural match.
|
||||
// * The live component is found via UnityEngine.Object.FindObjectOfType
|
||||
// (name-stable) rather than the SingletonMonoBehaviour<T> accessor.
|
||||
// * Execute(string) keeps its readable name on both builds (kept here as
|
||||
// a scripted-command entry point even though the hotkey drives the UI).
|
||||
internal static class DebugConsolePatches
|
||||
{
|
||||
private static bool _consoleVisible;
|
||||
private static bool _pollErrorLogged;
|
||||
private static int _probeFrame;
|
||||
private static bool _probeDone;
|
||||
|
||||
private static bool _inputResolved;
|
||||
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
|
||||
private static object? _toggleKeyValue; // boxed KeyCode enum value
|
||||
|
||||
private static bool _consoleResolved;
|
||||
private static Type? _consoleType;
|
||||
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||
private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type)
|
||||
|
||||
// Harmony prefix returning false → skips the original CheatManager
|
||||
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_inputResolved) ResolveInput();
|
||||
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
|
||||
// console object actually exists, so we learn that even if the
|
||||
// hotkey never fires.
|
||||
if (!_probeDone && _probeFrame++ > 180)
|
||||
{
|
||||
_probeDone = true;
|
||||
ProbeConsole();
|
||||
}
|
||||
if (_getKeyDown is null || _toggleKeyValue is null) return;
|
||||
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
|
||||
{
|
||||
Mod.Log.Msg($"[debugconsole] toggle key '{Mod.Cfg.DebugConsoleToggleKey}' detected");
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_pollErrorLogged) { _pollErrorLogged = true; Mod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic: reports whether a live DebugConsole component exists and
|
||||
// whether the show/hide method resolved — without needing a keypress.
|
||||
private static void ProbeConsole()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||
try
|
||||
{
|
||||
var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType });
|
||||
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Runs DebugConsole.Execute(command) directly — bypasses the UI/gate
|
||||
// entirely. Handy for scripted one-shots; the hotkey path uses the UI.
|
||||
public static void ExecuteCommand(string command)
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var execute = AccessTools.Method(_consoleType, "Execute", new[] { typeof(string) });
|
||||
if (execute is null) { Mod.Log.Warning("[debugconsole] Execute(string) not found"); return; }
|
||||
execute.Invoke(null, new object[] { command });
|
||||
Mod.Log.Msg($"[debugconsole] executed: {command}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] execute '{command}' failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void ResolveInput()
|
||||
{
|
||||
_inputResolved = true;
|
||||
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
|
||||
var inputType = Mod.ResolveType("UnityEngine.Input");
|
||||
if (keyCodeType is null || inputType is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
try { _toggleKeyValue = Enum.Parse(keyCodeType, Mod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[debugconsole] '{Mod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
|
||||
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
|
||||
}
|
||||
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||
if (_getKeyDown is null) Mod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
|
||||
else Mod.Log.Msg($"[debugconsole] hotkey armed: '{Mod.Cfg.DebugConsoleToggleKey}' (poll live)");
|
||||
}
|
||||
|
||||
private static void ResolveConsole()
|
||||
{
|
||||
_consoleResolved = true;
|
||||
_consoleType = Mod.ResolveType("RecRoom.Debugging.DebugConsole");
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
|
||||
|
||||
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
|
||||
// name, then a structural fallback that fits both builds.
|
||||
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(_consoleType);
|
||||
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
|
||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||
_findObjectOfType = objType is null ? null
|
||||
: AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) });
|
||||
if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found");
|
||||
}
|
||||
|
||||
// The console's show/hide is the single private, instance, void method
|
||||
// taking exactly one bool, excluding compiler-generated members (the
|
||||
// property backing setter on 2020.12 is also void(bool) but is marked
|
||||
// [CompilerGenerated]).
|
||||
private static MethodInfo? FindToggleByShape(Type t)
|
||||
{
|
||||
MethodInfo? found = null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||
if (found is not null) return null; // ambiguous — don't guess
|
||||
found = m;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static void Toggle()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
|
||||
try
|
||||
{
|
||||
var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType });
|
||||
if (instance is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||
return;
|
||||
}
|
||||
// Best-effort: flip the static "show button" enable so the
|
||||
// game's own Update keeps the console available instead of
|
||||
// re-hiding it. Failures are ignored (obfuscated builds).
|
||||
EnableShowButton();
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void EnableShowButton()
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
|
||||
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
|
||||
{
|
||||
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
|
||||
}
|
||||
}
|
||||
catch { /* best-effort — the toggle still works without it */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── 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;
|
||||
@@ -1592,9 +614,6 @@ internal static class ScreenSharePatches
|
||||
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));
|
||||
@@ -1625,7 +644,6 @@ internal static class ScreenSharePatches
|
||||
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)
|
||||
@@ -1645,55 +663,18 @@ internal static class ScreenSharePatches
|
||||
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
|
||||
{
|
||||
public static void HttpRequestSetUri_Prefix(ref Uri __0)
|
||||
{
|
||||
RewriteUriArg(ref __0, "httprequest-seturi-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpRequestPrepareUri_Prefix(ref Uri __0)
|
||||
{
|
||||
RewriteUriArg(ref __0, "httprequest-prepareuri-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpManagerSendRequestString_Prefix(ref string url)
|
||||
{
|
||||
RewriteString(ref url, "httpmanager-rewrite");
|
||||
@@ -1718,14 +699,24 @@ internal static class UriPatches
|
||||
{
|
||||
try
|
||||
{
|
||||
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (uriProp?.GetValue(request) is not Uri uri) return;
|
||||
if (request is null) return;
|
||||
var type = request.GetType();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return;
|
||||
if (TryRewriteUriField(request, type.GetField("<Uri>k__BackingField", flags), label)) return;
|
||||
|
||||
uriProp.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
|
||||
foreach (var prop in type.GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryRewriteUriProperty(request, prop, label)) return;
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryRewriteUriField(request, field, label)) return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1733,15 +724,51 @@ internal static class UriPatches
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteUriArg(ref Uri uri, string label)
|
||||
{
|
||||
if (uri is null) return;
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
|
||||
uri = rewritten;
|
||||
}
|
||||
|
||||
private static bool TryRewriteUriProperty(object request, PropertyInfo? prop, string label)
|
||||
{
|
||||
if (prop is null || !prop.CanRead || !prop.CanWrite) return false;
|
||||
if (prop.GetIndexParameters().Length != 0) return false;
|
||||
if (prop.GetValue(request) is not Uri uri) return false;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return false;
|
||||
|
||||
prop.SetValue(request, rewritten);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryRewriteUriField(object request, FieldInfo? field, string label)
|
||||
{
|
||||
if (field is null || field.IsInitOnly) return false;
|
||||
if (field.GetValue(request) is not Uri uri) return false;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return false;
|
||||
|
||||
field.SetValue(request, rewritten);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void RewriteString(ref string uriString, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uriString)) return;
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return;
|
||||
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||
var rewritten = uriString.Replace(".rec.net", "." + host);
|
||||
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) return;
|
||||
|
||||
var rewrittenUri = RewriteUri(uri);
|
||||
if (rewrittenUri is null) return;
|
||||
|
||||
var rewritten = rewrittenUri.ToString();
|
||||
if (rewritten == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||
uriString = rewritten;
|
||||
}
|
||||
|
||||
@@ -1760,582 +787,42 @@ internal static class UriPatches
|
||||
}.Uri;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PhotonPatches
|
||||
internal static class AntiCheatPatches
|
||||
{
|
||||
public static void PhotonAppIdOverride_Prefix()
|
||||
public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return;
|
||||
try
|
||||
{
|
||||
// PhotonNetwork.PhotonServerSettings — accessed via reflection
|
||||
// so the type-name resolution stays in one place. Mutating the
|
||||
// ScriptableObject in-place is what the BepInEx plugin does
|
||||
// too; the Photon Connect path reads AppID / VoiceAppID off
|
||||
// this object immediately after.
|
||||
var photonNetwork = Mod.Instance is null ? null : FindGameType("PhotonNetwork");
|
||||
if (photonNetwork is null) return;
|
||||
var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
|
||||
var settings = settingsProp?.GetValue(null);
|
||||
if (settings is null) return;
|
||||
|
||||
var settingsType = settings.GetType();
|
||||
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
|
||||
var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId");
|
||||
|
||||
var newId = Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
|
||||
|
||||
// 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);
|
||||
voiceProp?.SetValue(settings, newVoice);
|
||||
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice}");
|
||||
|
||||
// Force a deterministic Photon region so two watches running
|
||||
// this mod end up on the same Photon master and "JoinByName"
|
||||
// produces ONE shared room (not two parallel ones in eu/us
|
||||
// because each watch auto-picked a different best region).
|
||||
//
|
||||
// The 2020 watch's default HostType is BestRegion(4) — every
|
||||
// launch pings all Photon Cloud regions and picks the lowest
|
||||
// latency. Two players geographically apart hit different
|
||||
// "best" regions; same room name, two regions = two rooms.
|
||||
// Server-side our matchmaking response carries
|
||||
// `photonRegionId` but the watch's Photon code never reads
|
||||
// it; the only consumer is Matchmaking.SetPhotonRegionPings
|
||||
// which only reports pings *back* to the server.
|
||||
//
|
||||
// Strategy: switch HostType to PhotonCloud(1) and set
|
||||
// PreferredRegion. Prefer the server-told region (the most
|
||||
// recent Matchmaking.LocalRoomInstance.PhotonRegionId, if
|
||||
// any) over the config default — that way a server-side
|
||||
// region change propagates without redeploying the mod.
|
||||
ApplyForcedRegion(settings, settingsType);
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-appid] override failed: {ex.Message}"); }
|
||||
__result = "AAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
|
||||
/// and writes <c>PreferredRegion</c>. Honors any region the server
|
||||
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
|
||||
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
|
||||
/// instance has been received yet (initial boot).</summary>
|
||||
private static void ApplyForcedRegion(object settings, Type settingsType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hostTypeProp = settingsType.GetField("HostType")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType");
|
||||
var preferredProp = settingsType.GetField("PreferredRegion")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion");
|
||||
if (hostTypeProp is null || preferredProp is null)
|
||||
{
|
||||
Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// ServerSettings.HostingOption.PhotonCloud = 1 (verified in
|
||||
// Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum
|
||||
// type from the field/property so we don't depend on a
|
||||
// specific Il2Cpp proxy namespace.
|
||||
Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)hostTypeProp).PropertyType;
|
||||
Type? regionEnum = (preferredProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)preferredProp).PropertyType;
|
||||
|
||||
// Prefer server-provided region over config. We read it via
|
||||
// the same reflection style as the existing patches so the
|
||||
// Il2Cpp type-name suffix doesn't bite us.
|
||||
string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us";
|
||||
|
||||
object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud
|
||||
object regionValue;
|
||||
try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us");
|
||||
regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1
|
||||
region = "us";
|
||||
}
|
||||
|
||||
if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue);
|
||||
else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue);
|
||||
if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue);
|
||||
else ((PropertyInfo)preferredProp).SetValue(settings, regionValue);
|
||||
|
||||
Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Reads
|
||||
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> as a
|
||||
/// lowercase enum name (eu, us, asia, …). Returns null if no room
|
||||
/// instance has been received yet, or if any link in the chain is
|
||||
/// null. Server-told region wins over the static config default
|
||||
/// because the server is authoritative about which Photon region
|
||||
/// hosts the room the watch is about to join.</summary>
|
||||
private static string? ReadServerProvidedRegion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var matchmaking = FindGameType("RecNet.Matchmaking");
|
||||
var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localRoom is null) return null;
|
||||
var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom);
|
||||
if (regionObj is null) return null;
|
||||
// CloudRegionCode enum.ToString() gives the lowercase region
|
||||
// name (eu, us, asia, …) — see CloudRegionCode.cs.
|
||||
return regionObj.ToString();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// AuthValuesInjector_Prefix parked 2026-05-28 — never wired to a
|
||||
// harmony hook in this branch anyway. See
|
||||
// attic/AuthValuesInjector.cs.attic.
|
||||
|
||||
/// <summary>Postfix on <c>PUNNetworkManager.FJOLIPKKIBE</c> — the
|
||||
/// 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);
|
||||
}
|
||||
|
||||
internal static class SavePatches
|
||||
internal static class AntiTamperPatches
|
||||
{
|
||||
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
|
||||
// our currently-loaded room. Captured BEFORE the watch's caching
|
||||
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
|
||||
// (roomId, subRoomId) tuple — which is every post-save push for a
|
||||
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
|
||||
// would-be no-op setter call into a real transition.
|
||||
private static volatile string? s_lastPushedBlobName;
|
||||
private static long s_lastPushedRoomId;
|
||||
private static long s_lastPushedSubRoomId;
|
||||
|
||||
// Hook on Notifications.OnNotificationReceived(string json). Fires for
|
||||
// every SignalR message before any handler dispatch. Parses the JSON
|
||||
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
|
||||
public static void OnNotificationReceived_Prefix(string message)
|
||||
public static bool CreateHileWarningCallback_Prefix(bool shouldQuit)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
// Fast reject: only parse JSON if it could plausibly be a
|
||||
// SubscriptionUpdateRoom payload. Saves cycles on the (very
|
||||
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
|
||||
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
|
||||
return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!root.TryGetProperty("Id", out var idEl) ||
|
||||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
|
||||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
|
||||
return;
|
||||
if (!root.TryGetProperty("Msg", out var msg) ||
|
||||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!msg.TryGetProperty("Room", out var room) ||
|
||||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
long roomId = 0;
|
||||
if (room.TryGetProperty("RoomId", out var ridEl) &&
|
||||
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
roomId = ridEl.GetInt64();
|
||||
if (!msg.TryGetProperty("Scenes", out var scenes) ||
|
||||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
|
||||
scenes.GetArrayLength() == 0) return;
|
||||
var first = scenes[0];
|
||||
long subRoomId = 0;
|
||||
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
|
||||
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
subRoomId = sidEl.GetInt64();
|
||||
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
|
||||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
|
||||
var blob = blobEl.GetString();
|
||||
if (string.IsNullOrEmpty(blob)) return;
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = blob;
|
||||
Mod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void OnSubscriptionUpdateRoom_Prefix(object message)
|
||||
public static bool UnknownDllDetectedCallback_Prefix()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message is null) return;
|
||||
var dict = message;
|
||||
|
||||
// The watch parses this dict as Dictionary<string, object>
|
||||
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
|
||||
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
|
||||
// get_Item(key) — call it via reflection.
|
||||
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (getItem is null) return;
|
||||
|
||||
// The push wire shape from server (BuildRoomDetails) is
|
||||
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
|
||||
// on the Room sub-object; the scene's blob on Scenes[0].
|
||||
object? roomObj = TryGet(dict, getItem, "Room");
|
||||
object? scenesObj = TryGet(dict, getItem, "Scenes");
|
||||
if (roomObj is null || scenesObj is null) return;
|
||||
|
||||
long roomId = 0;
|
||||
long subRoomId = 0;
|
||||
try
|
||||
{
|
||||
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (roomItem is not null)
|
||||
{
|
||||
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
|
||||
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* roomId stays 0 — we'll log it and bail later */ }
|
||||
|
||||
// Scenes is an IL2CPP List<RoomScene>. Take the first
|
||||
// (current dorm has one scene; multi-scene rooms target
|
||||
// Scenes[currentSubRoomId] specifically, but the post-save
|
||||
// push always carries the scene that was just saved).
|
||||
object? firstScene = null;
|
||||
try
|
||||
{
|
||||
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
|
||||
}
|
||||
catch { /* leave firstScene null */ }
|
||||
if (firstScene is null) return;
|
||||
|
||||
string? newBlob = null;
|
||||
try
|
||||
{
|
||||
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (sceneItem is not null)
|
||||
{
|
||||
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
|
||||
newBlob = blob?.ToString();
|
||||
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
|
||||
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* leave newBlob null */ }
|
||||
|
||||
if (string.IsNullOrEmpty(newBlob)) return;
|
||||
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = newBlob;
|
||||
Mod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static object? TryGet(object dict, MethodInfo getItem, string key)
|
||||
{
|
||||
try { return getItem.Invoke(dict, new object[] { key }); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
|
||||
// path only when the new value differs from the cached field. The
|
||||
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
|
||||
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
|
||||
// save it calls the setter with the OLD blob — a no-op that skips
|
||||
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
|
||||
// pointed at the boot-time bytes, and PostSaveReloading deserializes
|
||||
// those over the live world — wiping any MakerPen object the user
|
||||
// just placed.
|
||||
//
|
||||
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
|
||||
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
|
||||
// after b__4 returns True, even though the SubscriptionUpdateRoom
|
||||
// push already carried Scenes[0].DataBlobName="v11".
|
||||
//
|
||||
// Strategy: intercept the setter. If the incoming value matches the
|
||||
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
|
||||
// newer version (the freshly-pushed one), substitute that into the
|
||||
// value parameter so the real setter sees a transition and fires
|
||||
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
|
||||
// Falls through silently when no override is needed.
|
||||
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
|
||||
{
|
||||
string currentForLog = "<unread>";
|
||||
string sceneForLog = "<unread>";
|
||||
string scenes0ForLog = "<unread>";
|
||||
try
|
||||
{
|
||||
if (__instance is null)
|
||||
{
|
||||
Mod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var rpmType = __instance.GetType();
|
||||
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (blobNameProp is null)
|
||||
{
|
||||
Mod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
|
||||
return;
|
||||
}
|
||||
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
|
||||
|
||||
var roomsType = FindGameType("RecNet.Rooms");
|
||||
if (roomsType is null)
|
||||
{
|
||||
Mod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var localScene = roomsType.GetProperty("LocalRoomScene",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localScene is not null)
|
||||
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
|
||||
|
||||
var localDetails = roomsType.GetProperty("LocalRoomDetails",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localDetails is not null)
|
||||
{
|
||||
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
|
||||
if (scenes is not null)
|
||||
{
|
||||
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
var first = indexer?.Invoke(scenes, new object[] { 0 });
|
||||
if (first is not null)
|
||||
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional trace of EVERYTHING we read so we can see in
|
||||
// the log exactly why we did or didn't override.
|
||||
Mod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
|
||||
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
|
||||
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
|
||||
|
||||
// Pick a candidate "newer" blob name. Priority order:
|
||||
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
|
||||
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
|
||||
// cache gate drops the push).
|
||||
// 2. LocalRoomScene.DataBlobName.
|
||||
// 3. Scenes[0].DataBlobName.
|
||||
string? pushedBlob = s_lastPushedBlobName;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
|
||||
|
||||
if (string.IsNullOrEmpty(pushedBlob)) return;
|
||||
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
|
||||
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
|
||||
|
||||
Mod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
|
||||
value = pushedBlob;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
|
||||
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
|
||||
}
|
||||
}
|
||||
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
internal static class FileHashCheckerPatches
|
||||
{
|
||||
public static bool InitializeCallback_Prefix(object? result)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
internal static class ToxModPatches
|
||||
{
|
||||
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
||||
{
|
||||
__result = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
internal static class TlsPatches
|
||||
{
|
||||
// Both BouncyCastle TLS authenticators have the same `void
|
||||
@@ -2348,319 +835,3 @@ internal static class TlsPatches
|
||||
return !Mod.Cfg.EnableTlsTrustBypass; // false skips original; true (bypass off) lets it run
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DiagnosticPatches
|
||||
{
|
||||
public static void Write(string message)
|
||||
{
|
||||
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
|
||||
try { Mod.Log.Msg(line); } catch { }
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Mod.DiagnosticsLogPath))
|
||||
File.AppendAllText(Mod.DiagnosticsLogPath, line + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void NamedMethod_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ChatPatches
|
||||
{
|
||||
private static bool s_logged;
|
||||
|
||||
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
|
||||
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
|
||||
// before raising the in-room Photon chat RPC; if it returns false
|
||||
// the coroutine bails silently and the sender doesn't even see
|
||||
// their own message echo.
|
||||
public static void CanLocalPlayerChat_Postfix(ref bool __result)
|
||||
{
|
||||
if (!__result && !s_logged)
|
||||
{
|
||||
s_logged = true;
|
||||
Mod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
|
||||
}
|
||||
__result = true;
|
||||
}
|
||||
|
||||
// Skip the original. Returning false from a Harmony prefix stops
|
||||
// the underlying call.
|
||||
public static bool RemoveInvalidCharactersFromMessage_Prefix()
|
||||
{
|
||||
Mod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void SendChatEmote_Prefix(string message)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
|
||||
}
|
||||
|
||||
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
|
||||
}
|
||||
|
||||
public static void RpcChatEmote_Prefix(string message)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
|
||||
}
|
||||
}
|
||||
|
||||
// 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)}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
// 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):
|
||||
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL; names
|
||||
// re-verified against the March-2023 dump — 2023 obfuscated names below):
|
||||
// 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).
|
||||
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset,
|
||||
// a Unity ScriptableObject)
|
||||
// → calls GameConfigurationAsset.FIFOMGAOOHH() ← builds the runtime
|
||||
// protobuf GameConfigurationData (GPIGCAJICEF) FROM the asset
|
||||
// → stores and applies it to the team manager.
|
||||
// Inside FIFOMGAOOHH the build loop reads asset.TeamConfigurations[i]
|
||||
// .MaxTeamSize (a typed int struct-array) and writes each straight into a
|
||||
// new GameTeamConfigurationData.TeamSize.
|
||||
//
|
||||
// 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 &
|
||||
// • bumping the asset on a 2s poll happened AFTER the builder 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.
|
||||
// • patching the protobuf getters 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
|
||||
// THE FIX: Harmony-PREFIX GameConfigurationAsset.FIFOMGAOOHH() 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
|
||||
@@ -42,87 +43,18 @@ 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;
|
||||
|
||||
// 2023 spawn path (verified in the March-2023 ISIL): the spawn driver
|
||||
// HCPLAILIHEL reads the team-player index from the roster entry
|
||||
// (GameTeamManager/BNBIBPFPJPJ.JPIHHGBLLNG()) and passes it straight into
|
||||
// the KMPAOJGOOCH spawn-point filter. December's intermediate helpers are
|
||||
// gone from this path — the out-param resolver is inlined (zero call
|
||||
// sites), and GetTeamPlayerIndex is only called by Charades/RecRally, so
|
||||
// normalizing it would touch non-quest games. The KMPAOJGOOCH index
|
||||
// argument is the single choke point: normalize it in a prefix and keep
|
||||
// the empty-filter relax postfix as the backstop.
|
||||
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;
|
||||
}
|
||||
return TryPatchRequiredSpawnIndexNormalizer(harmony);
|
||||
}
|
||||
|
||||
private static bool TryPatchRequiredSpawnIndexNormalizer(HarmonyLib.Harmony harmony)
|
||||
@@ -130,17 +62,17 @@ internal static class QuestTeamSize
|
||||
try
|
||||
{
|
||||
var spawnType = Mod.ResolveType("GameSpawnManager");
|
||||
var indexType = Mod.ResolveType("CCMLKDOHKJE");
|
||||
var indexType = Mod.ResolveType("NBCOFGIKKHM");
|
||||
if (spawnType is null || indexType is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer: type not found");
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.KMPAOJGOOCH index normalizer: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var target = AccessTools.Method(spawnType, "FKEICBPCMPJ");
|
||||
var target = AccessTools.Method(spawnType, "KMPAOJGOOCH");
|
||||
if (target is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer");
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.KMPAOJGOOCH index normalizer");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -148,74 +80,21 @@ internal static class 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}");
|
||||
Mod.Log.Error($"[patch-fail] GameSpawnManager.KMPAOJGOOCH 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.
|
||||
// PREFIX on GameSpawnManager.KMPAOJGOOCH(..., NBCOFGIKKHM index, ...).
|
||||
// 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 index the spawn-point filter compares against; the actual team
|
||||
// roster/cap remains raised.
|
||||
public static void NormalizeRequiredSpawnIndex_Prefix<T>(ref T __2) where T : struct
|
||||
{
|
||||
try
|
||||
@@ -226,13 +105,6 @@ internal static class QuestTeamSize
|
||||
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)
|
||||
{
|
||||
@@ -246,7 +118,7 @@ internal static class QuestTeamSize
|
||||
return value >= 4;
|
||||
}
|
||||
|
||||
// PREFIX on GameConfigurationAsset.GDECPANDBHJ() (asset → protobuf builder).
|
||||
// PREFIX on GameConfigurationAsset.FIFOMGAOOHH() (asset → protobuf builder).
|
||||
public static void Build_Prefix(object __instance)
|
||||
{
|
||||
try
|
||||
@@ -255,11 +127,7 @@ internal static class QuestTeamSize
|
||||
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;
|
||||
if (!IsAllowed(name)) return;
|
||||
|
||||
var arr = GetMember(__instance, "TeamConfigurations");
|
||||
int len = ArrLen(arr);
|
||||
@@ -276,24 +144,18 @@ internal static class QuestTeamSize
|
||||
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
|
||||
// POSTFIX on GameSpawnManager.KMPAOJGOOCH(..., 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
|
||||
// The caller (2023: HCPLAILIHEL) runs 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
|
||||
@@ -315,14 +177,7 @@ internal static class QuestTeamSize
|
||||
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)}");
|
||||
}
|
||||
CopyListItems(__0, __7);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// AuthValuesInjector — PARKED 2026-05-28 (december-2020-12-18)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
|
||||
// doesn't pick it up. Don't rename to .cs unless you're putting the
|
||||
// feature back.
|
||||
//
|
||||
// What this used to do
|
||||
// --------------------
|
||||
// Built a Photon AuthenticationValues with `userid` + `LoginLock`
|
||||
// custom-auth parameters from the in-game RecNet account state. The
|
||||
// server's /photon/customauth controller (PhotonCustomAuthController.cs)
|
||||
// validated those parameters against the player's /player/login lock.
|
||||
//
|
||||
// Status in this branch
|
||||
// ---------------------
|
||||
// The method existed but was **never actually wired to a harmony
|
||||
// hook** in this branch's OnLateInitializeMelon. December's Photon
|
||||
// AuthValues path goes through PhotonPatches.LogAuthValues_Postfix
|
||||
// (postfix on PUNNetworkManager.EMGGKBALALH), which only logs — it
|
||||
// doesn't inject. So this code was effectively dead in december.
|
||||
//
|
||||
// Removed because:
|
||||
// 1. Photon Cloud is currently configured with Custom Authentication
|
||||
// disabled at the AppId level (anonymous connects allowed), so
|
||||
// no server-side auth probe.
|
||||
// 2. Even when the watch did send AuthValues, the upstream Cloudflare
|
||||
// / Localtunnel layer 403'd Photon's auth probe before our
|
||||
// controller could decide anything.
|
||||
//
|
||||
// How to put it back
|
||||
// ------------------
|
||||
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic suffix).
|
||||
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the LoadConfig
|
||||
// line.
|
||||
// 3. Wire the harmony hook in OnLateInitializeMelon. The march branch's
|
||||
// pick of NetworkingPeer.CallAuthenticate doesn't fire in IL2CPP, so
|
||||
// consider hooking PUNNetworkManager.EMGGKBALALH postfix instead and
|
||||
// mutating its returned AuthValues in place — that's the mangled
|
||||
// method december verifies fires (LogAuthValues_Postfix already
|
||||
// proves this entry point works).
|
||||
// 4. Put `"InjectAuthValues": true` back in dorknet-clientmod.json.template.
|
||||
// 5. If you also want the server to enforce the lock, set
|
||||
// Photon__RequireCustomAuth=true on the server.
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
namespace DorkNet.ClientMod.Attic;
|
||||
|
||||
internal static class AuthValuesInjector
|
||||
{
|
||||
public static void AuthValuesInjector_Prefix(object __instance)
|
||||
{
|
||||
if (!Mod.Cfg.InjectAuthValues) return;
|
||||
try
|
||||
{
|
||||
// Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020
|
||||
// watch never sets them itself, so we set them right before
|
||||
// NetworkingPeer.CallAuthenticate forwards to the wire op. The
|
||||
// userid is RecNet.Core.LocalAccountId, the LoginLock comes
|
||||
// from PlayerPrefs key LoginLockTokenV2.
|
||||
// RecNet.Core.LocalAccountId is declared `int` in the game
|
||||
// build (dump.cs:573622 → 0x30 backing field). Convert via
|
||||
// Convert.ToInt64 so boxing as int still gives us the long we
|
||||
// need below; a direct (long) cast on boxed int would throw.
|
||||
var coreType = FindGameType("RecNet.Core");
|
||||
var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj);
|
||||
if (accountId <= 0)
|
||||
{
|
||||
Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)");
|
||||
return;
|
||||
}
|
||||
|
||||
var photonNetwork = FindGameType("PhotonNetwork");
|
||||
var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static);
|
||||
var existing = avProp?.GetValue(null);
|
||||
if (existing is not null)
|
||||
{
|
||||
var userIdProp = existing.GetType().GetProperty("UserId");
|
||||
var existingUserId = userIdProp?.GetValue(existing) as string;
|
||||
if (!string.IsNullOrEmpty(existingUserId)) return;
|
||||
}
|
||||
|
||||
var token = GetCurrentLockToken();
|
||||
|
||||
var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues");
|
||||
if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; }
|
||||
var av = Activator.CreateInstance(authValuesType);
|
||||
var authTypeProp = authValuesType.GetProperty("AuthType");
|
||||
if (authTypeProp is not null)
|
||||
{
|
||||
var customAuthValue = authTypeProp.PropertyType.IsEnum
|
||||
? Enum.ToObject(authTypeProp.PropertyType, 0)
|
||||
: Convert.ChangeType(0, authTypeProp.PropertyType);
|
||||
authTypeProp.SetValue(av, customAuthValue);
|
||||
}
|
||||
authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString());
|
||||
var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) });
|
||||
addParam?.Invoke(av, new object[] { "userid", accountId.ToString() });
|
||||
addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty });
|
||||
|
||||
avProp?.SetValue(null, av);
|
||||
Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "<missing>" : "<set>")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static string? GetCurrentLockToken()
|
||||
{
|
||||
try
|
||||
{
|
||||
var playerPrefs = FindGameType("UnityEngine.PlayerPrefs");
|
||||
var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) });
|
||||
var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty });
|
||||
if (string.IsNullOrEmpty(raw)) return null;
|
||||
const string key = "\"LockToken\":\"";
|
||||
var ix = raw.IndexOf(key, StringComparison.Ordinal);
|
||||
if (ix < 0) return null;
|
||||
ix += key.Length;
|
||||
var end = raw.IndexOf('"', ix);
|
||||
return end < 0 ? null : raw[ix..end];
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// FindGameType in the live Mod.cs is a delegate to Mod.ResolveType.
|
||||
// Use that on restore; this is just a placeholder to keep the file
|
||||
// compilable if someone untreats it without re-checking the wiring.
|
||||
private static Type? FindGameType(string name) => null;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"ServerHost": "{HOST}",
|
||||
"PhotonAppId": "{PHOTON_APPID}",
|
||||
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
|
||||
"PhotonCloudRegion": "{PHOTON_REGION}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||
"EnableDebugConsole": false,
|
||||
"DebugConsoleToggleKey": "BackQuote",
|
||||
"DevCommands": [],
|
||||
"QuestMaxTeamSize": 0,
|
||||
"QuestMaxTeamSizeRooms": [
|
||||
"CrimsonCauldron",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,309 @@
|
||||
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",
|
||||
// 2023 clients renamed the whole goto/* matchmaking surface
|
||||
// to matchmake/* (matchmake/room, /instance, /invite,
|
||||
// /chatinvite, …). Same controller as /goto, so it belongs
|
||||
// to the match service; without this it fell through to the
|
||||
// Monolith default owner.
|
||||
"/matchmake",
|
||||
"/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/roomconsumables",
|
||||
"/api/roomcurrencies",
|
||||
"/api/storefronts",
|
||||
"/commerce",
|
||||
"/econ",
|
||||
"/purchase",
|
||||
"/purchasecampaign",
|
||||
"/reminder",
|
||||
]),
|
||||
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 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);
|
||||
@@ -0,0 +1,38 @@
|
||||
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);
|
||||
@@ -0,0 +1,19 @@
|
||||
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);
|
||||
@@ -0,0 +1,16 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,3751 @@
|
||||
// DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
|
||||
//
|
||||
// The shipping ClientMod carries ONLY the patches needed to connect and
|
||||
// survive anti-cheat. All of the verbose diagnostic/tracing code lives
|
||||
// here: HTTP header/request tracing, join/quit/studio/room-load traces,
|
||||
// the process-level exception sink, auth-token tracing, the registration
|
||||
// dialog trace, and the in-game DebugConsole enable/toggle path. Drop
|
||||
// DorkNet.DebugMod.dll into the Mods folder next to DorkNet.ClientMod.dll
|
||||
// to turn all of it on; remove it to ship a quiet, minimal client.
|
||||
//
|
||||
// It is a fully self-contained MelonMod (its own Harmony instance + its
|
||||
// own copy of the reflection/patch helpers) so it neither needs nor
|
||||
// couples to ClientMod at load time. Debug knobs are read from the same
|
||||
// <MelonLoader>\UserData\dorknet-clientmod.json the ClientMod uses.
|
||||
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.DebugMod.DebugMod), "DorkNet DebugMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.DebugMod;
|
||||
|
||||
public class DebugMod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||
internal static DebugMod? Instance;
|
||||
internal static string? DiagnosticsLogPath;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _httpDiagnosticsRegistered;
|
||||
private bool _joinTracePatchesRegistered;
|
||||
private bool _quitTraceCoreRegistered;
|
||||
private bool _quitTraceGameComplete;
|
||||
private int _quitTraceRetryFrame;
|
||||
private bool _debugConsolePatchesComplete;
|
||||
private int _debugConsoleRetryFrame;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private bool _processExceptionHandlersRegistered;
|
||||
private bool _firstUpdateLogged;
|
||||
private bool _devCommandsDumped;
|
||||
private int _devCommandsDumpFrame;
|
||||
private int _devCommandsDumpTries;
|
||||
|
||||
public static class Cfg
|
||||
{
|
||||
// Log-only trace for the stuck first-dorm "Change Username?" dialog.
|
||||
public static bool TraceRegistrationDialog = true;
|
||||
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
|
||||
// (normally dev-gated) and silence CheatManager's tamper detectors so
|
||||
// movement/time commands don't drop you to the dorm. Default OFF. The
|
||||
// toggle key is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
public static string[] DevCommands = Array.Empty<string>();
|
||||
// Read-only one-shot: dump the full runtime command table to
|
||||
// UserData/dorknet-devcommands.txt. Default OFF.
|
||||
public static bool DumpDevCommands = false;
|
||||
}
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet DebugMod loading ===");
|
||||
LoadConfig();
|
||||
}
|
||||
|
||||
public override void OnLateInitializeMelon()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostic patches ===");
|
||||
RegisterProcessExceptionHandlers();
|
||||
RegisterHttpDiagnostics();
|
||||
RegisterQuitTracePatches(logMisses: true);
|
||||
RegisterStudioTracePatches();
|
||||
RegisterJoinTracePatches();
|
||||
// Auth-token setter trace (LBNJFPOLCDL.JHLKGBGJKKK(access, refresh, key)).
|
||||
// The shipping ClientMod used to register this unconditionally; it now
|
||||
// lives here with the rest of the diagnostics.
|
||||
TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK",
|
||||
args: new[] { typeof(string), typeof(string), typeof(string) },
|
||||
prefix: nameof(AuthPatches.CaptureTokenArgs_Prefix));
|
||||
// Deep game-side diagnostics: CheatManager tamper-detection logging,
|
||||
// the Unity Debug.LogError/LogException sink, Photon connect/join
|
||||
// tracing, and the room-load MoveNext state dumps. Unwired in the
|
||||
// shipping client (kept out of the minimal footprint); this is their
|
||||
// home now that diagnostics are opt-in.
|
||||
RegisterDiagnostics();
|
||||
if (Cfg.EnableDebugConsole)
|
||||
RegisterDebugConsolePatches(logMisses: true);
|
||||
Log.Msg("=== Diagnostic patches registered ===");
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (!_firstUpdateLogged)
|
||||
{
|
||||
_firstUpdateLogged = true;
|
||||
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
|
||||
}
|
||||
|
||||
if (!_quitTraceGameComplete && ++_quitTraceRetryFrame >= 300)
|
||||
{
|
||||
_quitTraceRetryFrame = 0;
|
||||
RegisterQuitTracePatches(logMisses: false);
|
||||
}
|
||||
|
||||
if (Cfg.EnableDebugConsole && !_debugConsolePatchesComplete && ++_debugConsoleRetryFrame >= 300)
|
||||
{
|
||||
_debugConsoleRetryFrame = 0;
|
||||
RegisterDebugConsolePatches(logMisses: false);
|
||||
}
|
||||
|
||||
if (Cfg.EnableDebugConsole)
|
||||
DebugConsolePatches.PollToggleKey();
|
||||
|
||||
// Retry the read-only command dump every ~5s (the config singleton only
|
||||
// exists after the ~26s load + dorm entry). Stops as soon as it finds a
|
||||
// populated runtime table, or after ~2.5 min of attempts.
|
||||
if (Cfg.DumpDevCommands && !_devCommandsDumped && ++_devCommandsDumpFrame >= 300)
|
||||
{
|
||||
_devCommandsDumpFrame = 0;
|
||||
if (DebugConsolePatches.DumpCommandDiagnostics() || ++_devCommandsDumpTries >= 30)
|
||||
_devCommandsDumped = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
private static void LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userData = ResolveUserDataDirectory();
|
||||
Directory.CreateDirectory(userData);
|
||||
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
|
||||
|
||||
var path = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Msg($"[config] no file at {path}; using debug defaults");
|
||||
return;
|
||||
}
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (TryGetConfigValue(r, "TraceRegistrationDialog", out var v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (TryGetConfigValue(r, "DumpDevCommands", out v)) Cfg.DumpDevCommands = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var commands = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||
commands.Add(e.GetString()!);
|
||||
Cfg.DevCommands = commands.ToArray();
|
||||
}
|
||||
Log.Msg($"[config] debug: debugConsole={Cfg.EnableDebugConsole}, " +
|
||||
$"dumpDevCommands={Cfg.DumpDevCommands}, devCommands={Cfg.DevCommands.Length}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
foreach (var holder in new[] { typeof(HttpTracePatches), typeof(AuthPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(MakerPenGiftPreviewPatches) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
}
|
||||
throw new MissingMethodException($"patch method '{name}' not found on any holder");
|
||||
}
|
||||
private bool TryPatch(string label, Type target, string methodName,
|
||||
Type[]? args, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = methodName == "ctor"
|
||||
? (MethodBase)target.GetConstructor(args ?? Type.EmptyTypes)!
|
||||
: (args is null ? AccessTools.Method(target, methodName)
|
||||
: AccessTools.Method(target, methodName, args));
|
||||
if (method is null)
|
||||
{
|
||||
// `logMiss` is throttled by the retry-loop caller — once per
|
||||
// 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;
|
||||
}
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {label}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[patch-fail] {label}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
private bool TryPatchByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var label = FormatPatchLabel(typeName, methodName, args);
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
||||
}
|
||||
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var label = FormatPatchLabel(typeName, methodName, args);
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
if (!TryPatchByName(typeName, methodName, args, prefix, postfix, logMiss))
|
||||
return false;
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
internal static Type? ResolveType(string name)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||
return cached;
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return CacheResolvedType(name, t);
|
||||
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
|
||||
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return CacheResolvedType(name, prefixed);
|
||||
// For namespaced types like "RecNet.Core", also try with
|
||||
// the Il2Cpp prefix on the namespace (i.e. "Il2CppRecNet.Core").
|
||||
var dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
|
||||
if (nsPrefixed is not null) return CacheResolvedType(name, nsPrefixed);
|
||||
}
|
||||
}
|
||||
catch { /* unloaded / dynamic asm — skip */ }
|
||||
}
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var t in GetLoadableTypes(asm))
|
||||
{
|
||||
if (MatchesRequestedType(t, name))
|
||||
return CacheResolvedType(name, t);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static Type CacheResolvedType(string name, Type type)
|
||||
{
|
||||
ResolvedTypeCache[name] = type;
|
||||
return type;
|
||||
}
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
|
||||
{
|
||||
try
|
||||
{
|
||||
return asm.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
var types = new List<Type>();
|
||||
foreach (var t in ex.Types)
|
||||
{
|
||||
if (t is not null) types.Add(t);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
}
|
||||
private static bool MatchesRequestedType(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
var dottedPrefixed = "Il2Cpp." + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) 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 ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
||||
{
|
||||
if (args is null) return $"{typeName}.{methodName}";
|
||||
var names = new List<string>();
|
||||
foreach (var arg in args)
|
||||
names.Add(arg.Name);
|
||||
return $"{typeName}.{methodName}({string.Join(",", names)})";
|
||||
}
|
||||
private static string FormatParameterTypes(ParameterInfo[] parameters)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var parameter in parameters)
|
||||
names.Add(parameter.ParameterType.Name);
|
||||
return string.Join(",", names);
|
||||
}
|
||||
private static bool TypeNameContains(Type type, string value)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains(value, StringComparison.Ordinal);
|
||||
}
|
||||
private static bool MethodNameContains(MethodInfo method, string value)
|
||||
=> method.Name.Contains(value, StringComparison.Ordinal);
|
||||
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;
|
||||
}
|
||||
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 ===");
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 RegisterHttpDiagnostics()
|
||||
{
|
||||
if (_httpDiagnosticsRegistered) return;
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatchBndRequestBuilderDiagnostics();
|
||||
complete &= TryPatchDiagnosticAllOverloads("BestHTTP.HTTPRequest", "CallCallback",
|
||||
postfix: nameof(HttpTracePatches.CallCallback_Postfix),
|
||||
finalizer: nameof(HttpTracePatches.CallCallback_Finalizer));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "AddHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "SetHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
|
||||
_httpDiagnosticsRegistered = complete;
|
||||
}
|
||||
private bool TryPatchBndRequestBuilderDiagnostics()
|
||||
{
|
||||
const string label = "BNDIAONDFFF.request-builder";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var type = ResolveType("BNDIAONDFFF");
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning("[patch-miss] BNDIAONDFFF request-builder: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var prefix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix)));
|
||||
var postfix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix)));
|
||||
var patched = 0;
|
||||
|
||||
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
var parameters = ctor.GetParameters();
|
||||
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(ctor, prefix: prefix, postfix: postfix);
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] BNDIAONDFFF..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != "FILJELOLBKK") continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: prefix, postfix: postfix);
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] BNDIAONDFFF.FILJELOLBKK({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] BNDIAONDFFF request-builder overloads");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
Log.Msg($"[patch-ok] BNDIAONDFFF request-builder x{patched}");
|
||||
return true;
|
||||
}
|
||||
private void RegisterQuitTracePatches(bool logMisses)
|
||||
{
|
||||
if (!_quitTraceCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(QuitTracePatches.ApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(QuitTracePatches.ApplicationQuitInt_Prefix),
|
||||
logMiss: logMisses);
|
||||
_quitTraceCoreRegistered = true;
|
||||
}
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM",
|
||||
prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "DGHJGMBNHCL",
|
||||
prefix: nameof(QuitTracePatches.CreateExitTask_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
_quitTraceGameComplete = complete;
|
||||
}
|
||||
private void RegisterDiagnostics()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostics ===");
|
||||
if (!_diagnosticCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
|
||||
_diagnosticCoreRegistered = true;
|
||||
}
|
||||
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
|
||||
Log.Msg("=== Diagnostics registered ===");
|
||||
}
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
// 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[]
|
||||
{
|
||||
"OnTimeCheatDetected",
|
||||
"OnObscuredTypeCheatDetected",
|
||||
"OnHeightCheatDetected",
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
})
|
||||
{
|
||||
TryPatchDiagnosticByName("CheatManager", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
RegisterDeepRoomDiagnostics(logMisses);
|
||||
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
|
||||
// pure-logging diagnostics that fired during Unity's very first
|
||||
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
|
||||
// methods) MonoMod's lazy CompileMethodHook on the trampoline
|
||||
// crashes the CLR with 0x80131506 the instant Awake JITs. The
|
||||
// hooks gave us no behavioral leverage — only a `[call] …` log
|
||||
// line — so they're disabled. If anyone needs Steam-side visibility
|
||||
// later, route it through the game's own log lines instead.
|
||||
return true;
|
||||
}
|
||||
private void RegisterDebugConsolePatches(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("RecRoom.Debugging.DebugConsole",
|
||||
"Awake",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.Awake_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("RecRoom.Debugging.DebugConsole",
|
||||
"InputField_OnEndEdit",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.InputFieldOnEndEdit_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
// Dev commands that change movement/time trip retail anti-cheat
|
||||
// callbacks. Suppress the three callbacks present in the 2023.03.21
|
||||
// dump; keep the legacy height detector as a best-effort optional
|
||||
// patch for adjacent builds without keeping this retry loop alive.
|
||||
complete &= TryPatchDiagnosticByName("CheatManager",
|
||||
"OnTimeCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("CheatManager",
|
||||
"OnObscuredTypeCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("CheatManager",
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: logMisses);
|
||||
TryPatchDiagnosticByName("CheatManager",
|
||||
"OnHeightCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: false);
|
||||
|
||||
_debugConsolePatchesComplete = complete;
|
||||
if (complete)
|
||||
Log.Msg("[debugconsole] restoration patches armed");
|
||||
}
|
||||
private bool TryPatchDiagnosticAllOverloads(string typeName, string methodName,
|
||||
string? prefix = null, string? postfix = null,
|
||||
string? finalizer = null, bool logMiss = true)
|
||||
{
|
||||
var label = $"{typeName}.{methodName}(*)";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
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: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)),
|
||||
finalizer: finalizer is null ? null : new HarmonyMethod(GetPatchMethod(finalizer)));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: no overloads matched");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
Log.Msg($"[patch-ok] {label} x{patched}");
|
||||
return true;
|
||||
}
|
||||
private bool RegisterDeepRoomDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
|
||||
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogError",
|
||||
args: new[] { typeof(object) },
|
||||
prefix: nameof(RoomLoadDiagnostics.LogError_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogException",
|
||||
args: new[] { typeof(Exception) },
|
||||
prefix: nameof(RoomLoadDiagnostics.LogException_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"ConnectUsingSettings",
|
||||
"ConnectToRegion",
|
||||
"JoinRoom",
|
||||
"JoinOrCreateRoom",
|
||||
"CreateRoom",
|
||||
"LeaveRoom",
|
||||
"Disconnect",
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticAllOverloads("PhotonNetwork", method,
|
||||
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
foreach (var item in new (string Type, string Method)[]
|
||||
{
|
||||
("CMMIJKCDKPL", "CDNPLAGEDBK"),
|
||||
("KGHEDLNPANK", "CDNPLAGEDBK"),
|
||||
("HMPNINFEDFB", "IJIOJAIBPKE"),
|
||||
("PhotonNetwork", "Disconnect"),
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticAllOverloads(item.Type, item.Method,
|
||||
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= PatchKnownRoomLoadMoveNext(logMisses);
|
||||
return true;
|
||||
}
|
||||
private void RegisterStudioTracePatches()
|
||||
{
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "RegisterPrefabs",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioRegisterPrefabs_Postfix));
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "TryGetPrefab",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioTryGetPrefab_Postfix));
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "IsKnownPrefab",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioIsKnownPrefab_Postfix));
|
||||
TryPatchByName("NLDBPDCNNCF", "FKLDMPMFLBD",
|
||||
prefix: nameof(RoomLoadDiagnostics.StudioUnityAssetFetch_Prefix));
|
||||
}
|
||||
private bool PatchKnownRoomLoadMoveNext(bool logMiss)
|
||||
{
|
||||
const string label = "room-load-state-machines.MoveNext";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var nameNeedles = new[]
|
||||
{
|
||||
"OMJKHJLFOCO", "AFMPFLKGABO",
|
||||
"AOEBBIFGIKI", "FINJCCNPHEP", "FDDFOKNJIFL", "GMNHJNAHAAI",
|
||||
"MHDOLJJPEAD", "LFGLDJCMJDN", "PFDIKJHMBDI", "ICAGCHKPIOM",
|
||||
"JEBACILNLJI", "DINBAFNHLPJ", "HLHPNABABME", "PAPLNIPKAMG",
|
||||
"IBEOONPEELF", "JHPHMNGOFLB", "NLDBPDCNNCF", "LONOBOBHMJL",
|
||||
"BNDIAONDFFF", "HOONLPFFADG", "PHODPEHLHGM",
|
||||
};
|
||||
|
||||
var patched = 0;
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
var match = false;
|
||||
foreach (var needle in nameNeedles)
|
||||
{
|
||||
if (fullName.IndexOf(needle, StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) continue;
|
||||
|
||||
IEnumerable<MethodInfo> methods;
|
||||
try { methods = AccessTools.GetDeclaredMethods(type); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
if (method.Name != "MoveNext") continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
finalizer: new HarmonyMethod(GetPatchMethod(nameof(RoomLoadDiagnostics.MoveNext_Finalizer))));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] room-load MoveNext {type.FullName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] room-load MoveNext {type.FullName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning("[patch-miss] room-load state-machine MoveNext hooks");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
private void RegisterProcessExceptionHandlers()
|
||||
{
|
||||
if (_processExceptionHandlersRegistered) return;
|
||||
_processExceptionHandlersRegistered = true;
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
DiagnosticPatches.Write($"[process-exception] unhandled terminating={e.IsTerminating} {RoomLoadDiagnostics.FormatException(ex, e.ExceptionObject)}");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write($"[process-exception] unobserved-task {RoomLoadDiagnostics.FormatException(e.Exception, e.Exception)}");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RoomLoadDiagnostics
|
||||
{
|
||||
private static readonly Dictionary<string, int> Counts = new();
|
||||
private static int _globalCount;
|
||||
|
||||
public static void LogError_Prefix(object? message)
|
||||
{
|
||||
DiagnosticPatches.Write($"[unity-error] LogError {Sanitize(message?.ToString() ?? "<null>", 1600)}");
|
||||
WriteStack("unity-error");
|
||||
}
|
||||
|
||||
public static void LogException_Prefix(Exception exception)
|
||||
{
|
||||
DiagnosticPatches.Write($"[unity-exception] {FormatException(exception, exception)}");
|
||||
WriteStack("unity-exception");
|
||||
}
|
||||
|
||||
public static void NamedCall_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("call:" + name, 80)) return;
|
||||
|
||||
DiagnosticPatches.Write($"[runtime-call] ENTER {name} [{Signature(__originalMethod)}]");
|
||||
if (IsImportantCall(name))
|
||||
WriteStack("runtime-call");
|
||||
}
|
||||
|
||||
public static Exception? NamedCall_Finalizer(Exception? __exception, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write($"[runtime-call] EXCEPTION {FullMethodName(__originalMethod)} {FormatException(__exception, __exception)}");
|
||||
WriteStack("runtime-call-exception");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void MoveNext_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("enter:" + name, 120)) return;
|
||||
DiagnosticPatches.Write($"[room-load] ENTER {name} state={DumpInstanceFields(__instance, 28)}");
|
||||
}
|
||||
|
||||
public static void MoveNext_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("exit:" + name, 120)) return;
|
||||
DiagnosticPatches.Write($"[room-load] EXIT {name} state={DumpInstanceFields(__instance, 28)}");
|
||||
}
|
||||
|
||||
public static Exception? MoveNext_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[room-load] EXCEPTION {FullMethodName(__originalMethod)} " +
|
||||
$"{FormatException(__exception, __exception)} state={DumpInstanceFields(__instance, 40)}");
|
||||
WriteStack("room-load-exception");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
// ── Studio baked-asset load trace ──────────────────────────────────
|
||||
// Pinpoints where the baked-Studio-scene load chain breaks. The room
|
||||
// joins but renders no custom geometry and logs "Expected a prefab,
|
||||
// but found none"; the server serves everything but the client never
|
||||
// requests a bundle. These probes answer: (1) does the client ever
|
||||
// call the RecNet "get unity asset" API (FKLDMPMFLBD); (2) does it
|
||||
// ever register baked prefabs; (3) which prefab GUIDs does it look up
|
||||
// and miss.
|
||||
|
||||
/// <summary>RecNet unity-asset getter
|
||||
/// <c>NLDBPDCNNCF.FKLDMPMFLBD(string unityAssetId, byte target, int version, CancellationToken)</c>
|
||||
/// — the call that builds <c>unity_assets/{id}/{target}/{version}</c>. If
|
||||
/// this never fires, the client decided not to fetch the bundle at all
|
||||
/// (trigger missing upstream); if it fires, the URL/host/response is the
|
||||
/// problem.</summary>
|
||||
public static void StudioUnityAssetFetch_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] UNITY-ASSET FETCH id={__0?.ToString() ?? "<null>"} " +
|
||||
$"target={__1?.ToString() ?? "?"} version={__2?.ToString() ?? "?"}");
|
||||
WriteStack("studio-assetfetch");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.RegisterPrefabs</c> — baked
|
||||
/// prefabs being registered after a bundle loads. If this never fires,
|
||||
/// no bundle was loaded.</summary>
|
||||
public static void StudioRegisterPrefabs_Postfix(object? __0)
|
||||
{
|
||||
int count = -1;
|
||||
try
|
||||
{
|
||||
if (__0 is not null)
|
||||
{
|
||||
var p = __0.GetType().GetProperty("Count");
|
||||
if (p?.GetValue(__0) is int c) count = c;
|
||||
}
|
||||
}
|
||||
catch { /* best-effort count */ }
|
||||
DiagnosticPatches.Write($"[studio] RegisterPrefabs count={count}");
|
||||
WriteStack("studio-registerprefabs");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.TryGetPrefab(Guid, out GameObject)</c>
|
||||
/// — the lookup that fails with "Expected a prefab, but found none".
|
||||
/// Logs the GUID + whether it resolved.</summary>
|
||||
public static void StudioTryGetPrefab_Postfix(object? __0, bool __result)
|
||||
{
|
||||
if (!ShouldLog("studio-trygetprefab", 200)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] TryGetPrefab guid={__0?.ToString() ?? "<null>"} found={__result}");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.IsKnownPrefab(Guid)</c>.</summary>
|
||||
public static void StudioIsKnownPrefab_Postfix(object? __0, bool __result)
|
||||
{
|
||||
if (!ShouldLog("studio-isknownprefab", 200)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] IsKnownPrefab guid={__0?.ToString() ?? "<null>"} known={__result}");
|
||||
}
|
||||
|
||||
public static string FormatException(Exception? ex, object? fallback)
|
||||
{
|
||||
if (ex is null) return fallback?.ToString() ?? "<null>";
|
||||
var builder = new StringBuilder();
|
||||
AppendException(builder, ex, 0);
|
||||
return Sanitize(builder.ToString(), 5000);
|
||||
}
|
||||
|
||||
private static void AppendException(StringBuilder builder, Exception ex, int depth)
|
||||
{
|
||||
if (depth > 4) return;
|
||||
if (builder.Length > 0) builder.Append(" | ");
|
||||
builder.Append(ex.GetType().FullName).Append(": ").Append(ex.Message);
|
||||
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
|
||||
builder.Append(" stack=").Append(ex.StackTrace);
|
||||
if (ex is AggregateException aggregate)
|
||||
{
|
||||
foreach (var inner in aggregate.InnerExceptions)
|
||||
AppendException(builder, inner, depth + 1);
|
||||
}
|
||||
else if (ex.InnerException is not null)
|
||||
{
|
||||
AppendException(builder, ex.InnerException, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldLog(string key, int perKeyLimit)
|
||||
{
|
||||
lock (Counts)
|
||||
{
|
||||
_globalCount++;
|
||||
if (_globalCount > 12000)
|
||||
return false;
|
||||
|
||||
Counts.TryGetValue(key, out var count);
|
||||
if (count >= perKeyLimit)
|
||||
return false;
|
||||
|
||||
Counts[key] = count + 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DumpInstanceFields(object? instance, int maxFields)
|
||||
{
|
||||
if (instance is null) return "<null>";
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? value;
|
||||
try { value = field.GetValue(instance); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
var described = DescribeValue(value, 400);
|
||||
if (ShouldExpandField(field, value))
|
||||
described += " " + DumpNestedFields(value, 16);
|
||||
parts.Add($"{field.Name}={described}");
|
||||
}
|
||||
|
||||
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 400)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldExpandField(FieldInfo field, object? value)
|
||||
{
|
||||
if (value is null) return false;
|
||||
if (value is string) return false;
|
||||
|
||||
var valueType = value.GetType();
|
||||
if (valueType.IsPrimitive || valueType.IsEnum || value is decimal)
|
||||
return false;
|
||||
if (value is IEnumerable)
|
||||
return false;
|
||||
|
||||
var fieldName = field.Name ?? string.Empty;
|
||||
var typeName = valueType.FullName ?? valueType.Name;
|
||||
return fieldName.IndexOf("this", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
typeName.IndexOf("OMJKHJLFOCO", StringComparison.Ordinal) >= 0 ||
|
||||
typeName.IndexOf("RoomPermissions", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static string DumpNestedFields(object value, int maxFields)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = value.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? nested;
|
||||
try { nested = field.GetValue(value); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{field.Name}={DescribeValue(nested, 220)}");
|
||||
}
|
||||
|
||||
return "{nested " + string.Join("; ", parts) + "}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"{{nested dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 220)}}}";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeValue(object? value, int limit)
|
||||
{
|
||||
if (value is null) return "null";
|
||||
|
||||
var type = value.GetType();
|
||||
if (value is string text) return "\"" + Sanitize(text, limit) + "\"";
|
||||
if (value is Exception ex) return FormatException(ex, ex);
|
||||
if (type.IsPrimitive || type.IsEnum || value is decimal)
|
||||
return Sanitize(value.ToString() ?? "<null>", limit);
|
||||
if (value is IEnumerable enumerable && value is not string)
|
||||
return DescribeEnumerable(enumerable, type, limit);
|
||||
|
||||
var rendered = value.ToString();
|
||||
if (string.IsNullOrWhiteSpace(rendered) || rendered == type.FullName)
|
||||
return "<" + type.FullName + ">";
|
||||
return "<" + type.FullName + " " + Sanitize(rendered, limit) + ">";
|
||||
}
|
||||
|
||||
private static string DescribeEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var count = 0;
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (count++ >= 6)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
parts.Add(DescribeValue(item, 120));
|
||||
}
|
||||
return "<" + type.FullName + " [" + Sanitize(string.Join(", ", parts), limit) + "]>";
|
||||
}
|
||||
|
||||
private static string Signature(MethodBase method)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
var parts = new List<string>(parameters.Length);
|
||||
foreach (var parameter in parameters)
|
||||
parts.Add(parameter.ParameterType.FullName ?? parameter.ParameterType.Name);
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "<signature unread>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FullMethodName(MethodBase method) =>
|
||||
$"{method.DeclaringType?.FullName ?? "<null>"}.{method.Name}";
|
||||
|
||||
private static bool IsImportantCall(string name) =>
|
||||
name.IndexOf("Disconnect", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("Join", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("Photon", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("CDNPLAGEDBK", StringComparison.Ordinal) >= 0 ||
|
||||
name.IndexOf("IJIOJAIBPKE", StringComparison.Ordinal) >= 0;
|
||||
|
||||
private static void WriteStack(string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write($"[{tag}-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[{tag}-stack] failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string value, int limit)
|
||||
{
|
||||
var redacted = Regex.Replace(value, @"(?i)(Bearer\s+)[-._~+/=A-Za-z0-9]+", "$1<redacted>");
|
||||
redacted = Regex.Replace(redacted, @"(?i)(access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)", "$1=<redacted>");
|
||||
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
return redacted.Length <= limit ? redacted : redacted[..limit] + "...<truncated>";
|
||||
}
|
||||
}
|
||||
internal static class DebugConsolePatches
|
||||
{
|
||||
private static bool _consoleVisible;
|
||||
private static bool _pollErrorLogged;
|
||||
private static int _probeFrame;
|
||||
private static bool _probeDone;
|
||||
|
||||
private static bool _inputResolved;
|
||||
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
|
||||
private static object? _toggleKeyValue; // boxed KeyCode enum value
|
||||
|
||||
private static bool _consoleResolved;
|
||||
private static Type? _consoleType;
|
||||
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||
private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string)
|
||||
private static List<ConfiguredCommand>? _commandCache;
|
||||
|
||||
// Harmony prefix returning false → skips the original CheatManager
|
||||
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
public static bool Awake_Prefix()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool InputFieldOnEndEdit_Prefix(object __instance)
|
||||
{
|
||||
var command = ReadConsoleInput(__instance);
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return true;
|
||||
|
||||
ExecuteCommand(command, __instance);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string FormatConfiguredCommandsForLog()
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
if (commands.Count == 0) return "<none>";
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var command in commands)
|
||||
parts.Add($"{command.Label}={command.Command}");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
// Polled every frame from DebugMod.OnUpdate while EnableDebugConsole is set.
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_inputResolved) ResolveInput();
|
||||
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
|
||||
// console object actually exists, so we learn that even if the
|
||||
// hotkey never fires.
|
||||
if (!_probeDone && _probeFrame++ > 180)
|
||||
{
|
||||
_probeDone = true;
|
||||
ProbeConsole();
|
||||
}
|
||||
if (_getKeyDown is null || _toggleKeyValue is null) return;
|
||||
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
|
||||
{
|
||||
DebugMod.Log.Msg($"[debugconsole] toggle key '{DebugMod.Cfg.DebugConsoleToggleKey}' detected");
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_pollErrorLogged) { _pollErrorLogged = true; DebugMod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic: reports whether a live DebugConsole component exists and
|
||||
// whether the show/hide method resolved — without needing a keypress.
|
||||
private static void ProbeConsole()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) { DebugMod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||
try
|
||||
{
|
||||
var active = FindConsoleInstance();
|
||||
DebugMod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||
}
|
||||
catch (Exception ex) { DebugMod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public static void ExecuteCommand(string command) => ExecuteCommand(command, consoleInstance: null);
|
||||
|
||||
private static void ExecuteCommand(string command, object? consoleInstance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return;
|
||||
|
||||
var output = RunCommand(command);
|
||||
if (consoleInstance is null)
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
consoleInstance = FindConsoleInstance();
|
||||
}
|
||||
if (consoleInstance is not null)
|
||||
WriteConsoleOutput(consoleInstance, output);
|
||||
|
||||
var parts = SplitCommandLine(command);
|
||||
var verb = parts.Count > 0 ? parts[0] : "<empty>";
|
||||
DebugMod.Log.Msg($"[debugconsole] command '{CleanLabel(verb)}': {FirstLine(output)}");
|
||||
}
|
||||
|
||||
private static string RunCommand(string commandLine)
|
||||
{
|
||||
var tokens = SplitCommandLine(commandLine);
|
||||
if (tokens.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var verb = tokens[0];
|
||||
if (IsHelpCommand(verb))
|
||||
return BuildCommandReferenceText("Debug Console Commands", maxCommands: 120);
|
||||
|
||||
var args = tokens.GetRange(1, tokens.Count - 1);
|
||||
if (!TryFindCommand(verb, out var command))
|
||||
return $"Unknown debug command: {verb}\n\n{BuildCommandReferenceText("Available Debug Console Commands", maxCommands: 80)}";
|
||||
|
||||
if (!command.HasRuntimeTarget)
|
||||
{
|
||||
var expanded = SplitCommandLine(command.Command);
|
||||
if (expanded.Count == 0)
|
||||
return $"Debug command '{verb}' has no backing command.";
|
||||
|
||||
var expandedArgs = expanded.Count > 1 ? expanded.GetRange(1, expanded.Count - 1) : new List<string>();
|
||||
expandedArgs.AddRange(args);
|
||||
if (!TryFindCommand(expanded[0], out command) || !command.HasRuntimeTarget)
|
||||
return $"Debug command '{verb}' points at '{command.Command}', but no runtime command metadata was found.";
|
||||
args = expandedArgs;
|
||||
}
|
||||
|
||||
if (!TryResolveCommandType(command, out var targetType, out var typeError))
|
||||
return typeError;
|
||||
|
||||
if (!TryFindCallableMethod(targetType!, command.MethodName, args, out var method, out var converted, out var methodError))
|
||||
return methodError;
|
||||
|
||||
object? target = null;
|
||||
if (!method!.IsStatic)
|
||||
{
|
||||
target = FindFirstInstance(targetType!);
|
||||
if (target is null)
|
||||
return $"Command '{command.MethodName}' needs a live {targetType!.Name} instance, but none was found.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = method.Invoke(target, converted);
|
||||
var suffix = result is null ? string.Empty : $"\nResult: {FormatResult(result)}";
|
||||
return $"Executed: {command.Label}{suffix}";
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
var inner = ex.InnerException ?? ex;
|
||||
return $"Command '{command.MethodName}' failed: {inner.GetType().Name}: {inner.Message}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Command '{command.MethodName}' failed: {ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHelpCommand(string verb) =>
|
||||
string.Equals(verb, "help", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(verb, "?", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(verb, "ListRegisteredCommands", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool TryResolveCommandType(ConfiguredCommand command, out Type? type, out string error)
|
||||
{
|
||||
foreach (var name in new[] { command.ObfuscatedDeclaringType, command.DeclaringType })
|
||||
{
|
||||
var normalized = NormalizeTypeName(name);
|
||||
if (string.IsNullOrWhiteSpace(normalized)) continue;
|
||||
type = DebugMod.ResolveType(normalized);
|
||||
if (type is not null)
|
||||
{
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
type = null;
|
||||
error = $"Command '{command.MethodName}' has no resolvable declaring type (declaring='{command.DeclaringType}', obfuscated='{command.ObfuscatedDeclaringType}').";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeTypeName(string value)
|
||||
{
|
||||
value = (value ?? string.Empty).Trim();
|
||||
var comma = value.IndexOf(',');
|
||||
return comma >= 0 ? value[..comma].Trim() : value;
|
||||
}
|
||||
|
||||
private static bool TryFindCallableMethod(Type type, string methodName, List<string> args,
|
||||
out MethodInfo? method, out object?[] converted, out string error)
|
||||
{
|
||||
foreach (var candidate in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
|
||||
{
|
||||
if (candidate.Name != methodName) continue;
|
||||
if (candidate.IsAbstract || candidate.ContainsGenericParameters) continue;
|
||||
if (TryConvertArguments(candidate.GetParameters(), args, out converted))
|
||||
{
|
||||
method = candidate;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
method = null;
|
||||
converted = Array.Empty<object?>();
|
||||
error = $"No overload of {type.Name}.{methodName} accepts {args.Count} argument(s): {string.Join(" ", args)}";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryConvertArguments(ParameterInfo[] parameters, List<string> args, out object?[] converted)
|
||||
{
|
||||
converted = new object?[parameters.Length];
|
||||
var argIndex = 0;
|
||||
|
||||
for (var i = 0; i < parameters.Length; i++)
|
||||
{
|
||||
var parameter = parameters[i];
|
||||
if (IsVector3(parameter.ParameterType) && args.Count - argIndex >= 3)
|
||||
{
|
||||
if (!TryParseFloat(args[argIndex], out var x) ||
|
||||
!TryParseFloat(args[argIndex + 1], out var y) ||
|
||||
!TryParseFloat(args[argIndex + 2], out var z) ||
|
||||
!TryCreateVector3(parameter.ParameterType, x, y, z, out converted[i]))
|
||||
return false;
|
||||
argIndex += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argIndex >= args.Count)
|
||||
{
|
||||
if (parameter.HasDefaultValue)
|
||||
{
|
||||
converted[i] = parameter.DefaultValue;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryConvertArgument(args[argIndex], parameter.ParameterType, out converted[i]))
|
||||
return false;
|
||||
argIndex++;
|
||||
}
|
||||
|
||||
return argIndex == args.Count;
|
||||
}
|
||||
|
||||
private static bool TryConvertArgument(string value, Type targetType, out object? converted)
|
||||
{
|
||||
converted = null;
|
||||
|
||||
if (targetType == typeof(string) || targetType.FullName == "System.String")
|
||||
{
|
||||
converted = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
var nullable = Nullable.GetUnderlyingType(targetType);
|
||||
if (nullable is not null)
|
||||
targetType = nullable;
|
||||
|
||||
if (targetType == typeof(bool))
|
||||
{
|
||||
if (bool.TryParse(value, out var b))
|
||||
{
|
||||
converted = b;
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) ||
|
||||
value == "1")
|
||||
{
|
||||
converted = true;
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "no", StringComparison.OrdinalIgnoreCase) ||
|
||||
value == "0")
|
||||
{
|
||||
converted = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (targetType.IsEnum)
|
||||
{
|
||||
try
|
||||
{
|
||||
converted = Enum.Parse(targetType, value, ignoreCase: true);
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (targetType == typeof(int)) { converted = int.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(long)) { converted = long.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(float)) { converted = float.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(double)) { converted = double.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(short)) { converted = short.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(byte)) { converted = byte.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
}
|
||||
catch { return false; }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsVector3(Type type) =>
|
||||
type.Name == "Vector3" && (type.Namespace == "UnityEngine" || type.FullName?.EndsWith(".Vector3", StringComparison.Ordinal) == true);
|
||||
|
||||
private static bool TryParseFloat(string value, out float result) =>
|
||||
float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result);
|
||||
|
||||
private static bool TryCreateVector3(Type vectorType, float x, float y, float z, out object? vector)
|
||||
{
|
||||
vector = null;
|
||||
try
|
||||
{
|
||||
var ctor = vectorType.GetConstructor(new[] { typeof(float), typeof(float), typeof(float) });
|
||||
if (ctor is null) return false;
|
||||
vector = ctor.Invoke(new object[] { x, y, z });
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static object? FindFirstInstance(Type type)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppType = ToIl2CppType(type);
|
||||
if (il2cppType is null) return null;
|
||||
var resources = DebugMod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var item = ArrItem(all, i);
|
||||
if (item is not null) return item;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatResult(object result)
|
||||
{
|
||||
var text = result.ToString() ?? result.GetType().Name;
|
||||
return text.Length <= 500 ? text : text[..500] + "...";
|
||||
}
|
||||
|
||||
private static string FirstLine(string value)
|
||||
{
|
||||
value = value.Replace("\r", "");
|
||||
var idx = value.IndexOf('\n');
|
||||
return idx >= 0 ? value[..idx] : value;
|
||||
}
|
||||
|
||||
private static List<string> SplitCommandLine(string value)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(value)) return result;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
var quote = '\0';
|
||||
var escaping = false;
|
||||
|
||||
foreach (var ch in value.Trim())
|
||||
{
|
||||
if (escaping)
|
||||
{
|
||||
sb.Append(ch);
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '\\')
|
||||
{
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inQuotes)
|
||||
{
|
||||
if (ch == quote)
|
||||
{
|
||||
inQuotes = false;
|
||||
continue;
|
||||
}
|
||||
sb.Append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch is '"' or '\'')
|
||||
{
|
||||
inQuotes = true;
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(ch))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
result.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(ch);
|
||||
}
|
||||
|
||||
if (escaping) sb.Append('\\');
|
||||
if (sb.Length > 0) result.Add(sb.ToString());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveInput()
|
||||
{
|
||||
_inputResolved = true;
|
||||
var keyCodeType = DebugMod.ResolveType("UnityEngine.KeyCode");
|
||||
var inputType = DebugMod.ResolveType("UnityEngine.Input");
|
||||
if (keyCodeType is null || inputType is null)
|
||||
{
|
||||
DebugMod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
try { _toggleKeyValue = Enum.Parse(keyCodeType, DebugMod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] '{DebugMod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
|
||||
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
|
||||
}
|
||||
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||
if (_getKeyDown is null) DebugMod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
|
||||
else DebugMod.Log.Msg($"[debugconsole] hotkey armed: '{DebugMod.Cfg.DebugConsoleToggleKey}' (poll live)");
|
||||
}
|
||||
|
||||
private static void ResolveConsole()
|
||||
{
|
||||
_consoleResolved = true;
|
||||
_consoleType = DebugMod.ResolveType("RecRoom.Debugging.DebugConsole");
|
||||
if (_consoleType is null) { DebugMod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
|
||||
|
||||
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
|
||||
// name, then a structural fallback that fits both builds.
|
||||
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "KIAMDEDKFBC", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(_consoleType);
|
||||
if (_toggleMethod is null) DebugMod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
}
|
||||
|
||||
// The console's show/hide is the single private, instance, void method
|
||||
// taking exactly one bool, excluding compiler-generated members (the
|
||||
// property backing setter on 2020.12 is also void(bool) but is marked
|
||||
// [CompilerGenerated]).
|
||||
private static MethodInfo? FindToggleByShape(Type t)
|
||||
{
|
||||
MethodInfo? found = null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||
if (found is not null) return null; // ambiguous — don't guess
|
||||
found = m;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static void Toggle()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null || _toggleMethod is null) return;
|
||||
try
|
||||
{
|
||||
var instance = FindConsoleInstance();
|
||||
if (instance is null)
|
||||
{
|
||||
DebugMod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||
return;
|
||||
}
|
||||
// Best-effort: flip the static "show button" enable so the
|
||||
// game's own Update keeps the console available instead of
|
||||
// re-hiding it. Failures are ignored (obfuscated builds).
|
||||
EnableShowButton();
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||
if (_consoleVisible)
|
||||
ShowConfiguredCommands(instance);
|
||||
DebugMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
}
|
||||
catch (Exception ex) { DebugMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static object? FindConsoleInstance()
|
||||
{
|
||||
if (_consoleType is null) return null;
|
||||
try
|
||||
{
|
||||
var il2cppType = ToIl2CppType(_consoleType);
|
||||
if (il2cppType is null) return null;
|
||||
|
||||
var resources = DebugMod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var item = ArrItem(all, i);
|
||||
if (item is not null) return item;
|
||||
}
|
||||
|
||||
var objType = DebugMod.ResolveType("UnityEngine.Object");
|
||||
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
|
||||
return findOne?.Invoke(null, new[] { il2cppType });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] FindConsoleInstance failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowConfiguredCommands(object instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
|
||||
if (_setConsoleText is null)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}");
|
||||
return;
|
||||
}
|
||||
|
||||
_setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() });
|
||||
DebugMod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] failed to display configured commands: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildConfiguredCommandText()
|
||||
{
|
||||
return BuildCommandReferenceText("DorkNet debug commands", maxCommands: 80) + "\n\nType a command and press Enter.";
|
||||
}
|
||||
|
||||
private static string ReadConsoleInput(object consoleInstance)
|
||||
{
|
||||
var input = FindConsoleInput(consoleInstance);
|
||||
return input is null ? string.Empty : ReadText(input);
|
||||
}
|
||||
|
||||
private static void WriteConsoleOutput(object consoleInstance, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_consoleType is not null)
|
||||
{
|
||||
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
|
||||
if (_setConsoleText is not null && _setConsoleText.DeclaringType?.IsInstanceOfType(consoleInstance) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
_setConsoleText.Invoke(consoleInstance, new object[] { text });
|
||||
return;
|
||||
}
|
||||
catch (TargetException)
|
||||
{
|
||||
// The live IL2CPP proxy can differ from the resolved wrapper type.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var input = FindConsoleInput(consoleInstance);
|
||||
if (input is not null)
|
||||
SetText(input, text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] output write failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object? FindConsoleInput(object consoleInstance)
|
||||
{
|
||||
foreach (var name in new[] { "KECJGKLKFGP", "inputField", "InputField" })
|
||||
{
|
||||
var value = GetMemberValue(consoleInstance, name);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
|
||||
var type = consoleInstance.GetType();
|
||||
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
if (!LooksLikeInputField(prop.PropertyType) || !prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try { if (prop.GetValue(consoleInstance) is { } value) return value; } catch { }
|
||||
}
|
||||
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
if (!LooksLikeInputField(field.FieldType)) continue;
|
||||
try { if (field.GetValue(consoleInstance) is { } value) return value; } catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool LooksLikeInputField(Type type) =>
|
||||
type.Name.IndexOf("InputField", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
type.FullName?.IndexOf("InputField", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
private static string ReadText(object textObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = textObject.GetType();
|
||||
var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?? type.GetProperty("Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop is not null && prop.CanRead && prop.GetValue(textObject) is string s)
|
||||
return s;
|
||||
|
||||
var getter = type.GetMethod("get_text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null)
|
||||
?? type.GetMethod("get_Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null);
|
||||
return getter?.Invoke(textObject, Array.Empty<object>()) as string ?? string.Empty;
|
||||
}
|
||||
catch { return string.Empty; }
|
||||
}
|
||||
|
||||
private static void SetText(object textObject, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = textObject.GetType();
|
||||
var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?? type.GetProperty("Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop is not null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(textObject, text);
|
||||
return;
|
||||
}
|
||||
|
||||
var setter = type.GetMethod("set_text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null)
|
||||
?? type.GetMethod("set_Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null);
|
||||
setter?.Invoke(textObject, new object[] { text });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static string BuildCommandReferenceText(string header, int maxCommands = int.MaxValue)
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(header);
|
||||
sb.AppendLine();
|
||||
if (commands.Count == 0)
|
||||
{
|
||||
sb.AppendLine("No DebugConsoleCommandConfig commands found.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var visible = Math.Min(commands.Count, maxCommands);
|
||||
for (var i = 0; i < visible; i++)
|
||||
sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}");
|
||||
if (visible < commands.Count)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"+ {commands.Count - visible} more commands available through the debug console.");
|
||||
}
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
internal static List<ConfiguredCommand> GetAvailableCommands()
|
||||
{
|
||||
if (_commandCache is not null) return _commandCache;
|
||||
|
||||
var commands = new List<ConfiguredCommand>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AddConfiguredCommands(commands, seen);
|
||||
AddRuntimeCommands(commands, seen);
|
||||
commands.Sort((a, b) => string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase));
|
||||
_commandCache = commands;
|
||||
DebugMod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)");
|
||||
return commands;
|
||||
}
|
||||
|
||||
// Read-only one-shot diagnostic (Cfg.DumpDevCommands). Writes a full report
|
||||
// to UserData/dorknet-devcommands.txt: does a live DebugConsole exist, and
|
||||
// for every runtime command meta, does its declaring type resolve and does
|
||||
// a method with that name exist on it (i.e. is it actually callable on this
|
||||
// obfuscated build). Invokes NOTHING and touches no game UI — safe to run
|
||||
// in a live session. This is what turns the text-console path from guessing
|
||||
// into engineering: the resolvable/total tally says whether the feature is
|
||||
// buildable as-is or needs an obfuscation name map.
|
||||
// Returns true once it observes a populated runtime command table (so the
|
||||
// caller can stop retrying); false while the config singleton isn't up yet.
|
||||
public static bool DumpCommandDiagnostics()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int runtime = 0;
|
||||
void W(string s) { sb.Append(s).Append('\n'); DebugMod.Log.Msg("[devcmd] " + s); }
|
||||
|
||||
try
|
||||
{
|
||||
// Force a fresh scan — an early call (before the config singleton
|
||||
// loads) must not poison the cache with an empty table.
|
||||
_commandCache = null;
|
||||
W("=== DorkNet dev-command diagnostic ===");
|
||||
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
object? liveConsole = null;
|
||||
try { liveConsole = FindConsoleInstance(); } catch (Exception ex) { W($"FindConsoleInstance threw: {ex.Message}"); }
|
||||
W($"DebugConsole type resolved : {_consoleType?.FullName ?? "<none>"}");
|
||||
W($"DebugConsole live instance : {(liveConsole is null ? "NULL (UI shell not instantiated — text console can't be shown, would need our own UI)" : "FOUND (UI can be driven)")}");
|
||||
W($"toggle method : {_toggleMethod?.Name ?? "<none>"}");
|
||||
W("");
|
||||
|
||||
List<ConfiguredCommand> commands;
|
||||
try { commands = GetAvailableCommands(); }
|
||||
catch (Exception ex) { W($"GetAvailableCommands threw: {ex}"); commands = new List<ConfiguredCommand>(); }
|
||||
|
||||
int typeOk = 0, methodOk = 0;
|
||||
var samplesOk = new List<string>();
|
||||
var samplesBad = new List<string>();
|
||||
|
||||
foreach (var c in commands)
|
||||
{
|
||||
if (!c.HasRuntimeTarget) continue;
|
||||
runtime++;
|
||||
|
||||
var resolvedType = TryResolveCommandType(c, out var t, out _) ? t : null;
|
||||
if (resolvedType is null)
|
||||
{
|
||||
if (samplesBad.Count < 30)
|
||||
samplesBad.Add($" [no-type] {c.MethodName} (declaring='{c.DeclaringType}' obf='{c.ObfuscatedDeclaringType}')");
|
||||
continue;
|
||||
}
|
||||
typeOk++;
|
||||
|
||||
var hasMethod = false;
|
||||
foreach (var m in resolvedType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
|
||||
if (m.Name == c.MethodName) { hasMethod = true; break; }
|
||||
|
||||
if (hasMethod)
|
||||
{
|
||||
methodOk++;
|
||||
if (samplesOk.Count < 40) samplesOk.Add($" [OK] {resolvedType.Name}.{c.MethodName}");
|
||||
}
|
||||
else if (samplesBad.Count < 30)
|
||||
{
|
||||
samplesBad.Add($" [no-method] {resolvedType.Name}.{c.MethodName} (type resolved, method name not present → obfuscated/stripped)");
|
||||
}
|
||||
}
|
||||
|
||||
W($"total commands in table : {commands.Count}");
|
||||
W($"runtime-target commands : {runtime}");
|
||||
W($" declaring type resolves : {typeOk}");
|
||||
W($" method callable by name : {methodOk} <-- this number decides feasibility");
|
||||
W("");
|
||||
W("--- sample CALLABLE commands ---");
|
||||
foreach (var s in samplesOk) W(s);
|
||||
W("");
|
||||
W("--- sample UNRESOLVED commands ---");
|
||||
foreach (var s in samplesBad) W(s);
|
||||
W("");
|
||||
W("--- FULL runtime command list (label | methodName | declaringType | obfuscatedDeclaringType) ---");
|
||||
foreach (var c in commands)
|
||||
{
|
||||
if (!c.HasRuntimeTarget) continue;
|
||||
sb.Append(" ").Append(c.Label).Append(" | ").Append(c.MethodName)
|
||||
.Append(" | ").Append(c.DeclaringType).Append(" | ").Append(c.ObfuscatedDeclaringType).Append('\n');
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sb.Append("DIAGNOSTIC FAILED: ").Append(ex).Append('\n');
|
||||
DebugMod.Log.Warning($"[devcmd] diagnostic failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Only persist the report once the table is actually populated, so an
|
||||
// early empty attempt doesn't overwrite a good report from a later try.
|
||||
if (runtime > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(DebugMod.DiagnosticsLogPath) ?? ".";
|
||||
var outPath = Path.Combine(dir, "dorknet-devcommands.txt");
|
||||
File.WriteAllText(outPath, sb.ToString());
|
||||
DebugMod.Log.Msg($"[devcmd] wrote {outPath} ({runtime} runtime commands)");
|
||||
}
|
||||
catch (Exception ex) { DebugMod.Log.Warning($"[devcmd] could not write report: {ex.Message}"); }
|
||||
}
|
||||
else
|
||||
{
|
||||
DebugMod.Log.Msg("[devcmd] command table not populated yet — will retry");
|
||||
}
|
||||
|
||||
return runtime > 0;
|
||||
}
|
||||
|
||||
private static bool TryFindCommand(string verb, out ConfiguredCommand command)
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
foreach (var candidate in commands)
|
||||
{
|
||||
if (!candidate.HasRuntimeTarget) continue;
|
||||
if (CommandMatches(candidate, verb))
|
||||
{
|
||||
command = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
foreach (var candidate in commands)
|
||||
{
|
||||
if (CommandMatches(candidate, verb))
|
||||
{
|
||||
command = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
command = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CommandMatches(ConfiguredCommand command, string verb)
|
||||
{
|
||||
if (string.Equals(command.MethodName, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(command.Command, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(command.Label, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
var shortLabel = command.Label;
|
||||
var dot = shortLabel.LastIndexOf('.');
|
||||
if (dot >= 0 && dot < shortLabel.Length - 1)
|
||||
shortLabel = shortLabel[(dot + 1)..];
|
||||
if (string.Equals(shortLabel, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
var commandTokens = SplitCommandLine(command.Command);
|
||||
return commandTokens.Count > 0 && string.Equals(commandTokens[0], verb, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void AddConfiguredCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
foreach (var entry in DebugMod.Cfg.DevCommands)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||
var split = entry.IndexOf('=');
|
||||
var label = split >= 0 ? entry[..split].Trim() : entry.Trim();
|
||||
var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim();
|
||||
if (string.IsNullOrWhiteSpace(command)) continue;
|
||||
if (string.IsNullOrWhiteSpace(label)) label = command;
|
||||
var parts = SplitCommandLine(command);
|
||||
var methodName = parts.Count > 0 ? parts[0] : command;
|
||||
AddCommand(commands, seen, label, command, declaringType: string.Empty, obfuscatedDeclaringType: string.Empty, methodName, isRuntime: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRuntimeCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfgType = DebugMod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig");
|
||||
if (cfgType is null)
|
||||
{
|
||||
DebugMod.Log.Warning("[debugconsole] DebugConsoleCommandConfig type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var method in cfgType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (method.GetParameters().Length != 0) continue;
|
||||
if (!cfgType.IsAssignableFrom(method.ReturnType)) continue;
|
||||
try { AddCommandConfig(method.Invoke(null, Array.Empty<object>()), commands, seen); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
var il2cppType = ToIl2CppType(cfgType);
|
||||
var resources = DebugMod.ResolveType("UnityEngine.Resources");
|
||||
if (il2cppType is not null && resources is not null)
|
||||
{
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
AddCommandConfig(ArrItem(all, i), commands, seen);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] runtime command scan failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCommandConfig(object? config, List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
if (config is null) return;
|
||||
var metas = GetMemberValue(config, "Metas");
|
||||
var count = ArrLen(metas);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var meta = ArrItem(metas, i);
|
||||
if (meta is null) continue;
|
||||
var methodName = ReadStringMember(meta, "MethodName");
|
||||
if (string.IsNullOrWhiteSpace(methodName)) continue;
|
||||
var declaringType = ReadStringMember(meta, "DeclaringType");
|
||||
var obfuscatedDeclaringType = ReadStringMember(meta, "ObfuscatedDeclaringType");
|
||||
var label = string.IsNullOrWhiteSpace(declaringType)
|
||||
? methodName
|
||||
: $"{ShortTypeName(declaringType)}.{methodName}";
|
||||
AddCommand(commands, seen, label, methodName, declaringType, obfuscatedDeclaringType, methodName, isRuntime: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCommand(List<ConfiguredCommand> commands, HashSet<string> seen, string label, string command,
|
||||
string declaringType, string obfuscatedDeclaringType, string methodName, bool isRuntime)
|
||||
{
|
||||
label = CleanLabel(label);
|
||||
command = (command ?? string.Empty).Trim();
|
||||
methodName = (methodName ?? string.Empty).Trim();
|
||||
if (command.Length == 0 || methodName.Length == 0) return;
|
||||
|
||||
var key = isRuntime
|
||||
? $"{NormalizeTypeName(obfuscatedDeclaringType)}|{NormalizeTypeName(declaringType)}|{methodName}"
|
||||
: $"configured|{label}|{command}";
|
||||
if (!seen.Add(key)) return;
|
||||
|
||||
commands.Add(new ConfiguredCommand(label, command, declaringType, obfuscatedDeclaringType, methodName, isRuntime));
|
||||
}
|
||||
|
||||
private static object? GetMemberValue(object instance, string name)
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
|
||||
{
|
||||
try { return prop.GetValue(instance); } catch { }
|
||||
}
|
||||
|
||||
var field = type.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field is not null)
|
||||
{
|
||||
try { return field.GetValue(instance); } catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReadStringMember(object instance, string name)
|
||||
{
|
||||
return GetMemberValue(instance, name)?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string CleanLabel(string label)
|
||||
{
|
||||
label = (label ?? string.Empty).Trim();
|
||||
if (label.Length == 0) return "Command";
|
||||
|
||||
var cleaned = new char[label.Length];
|
||||
for (var i = 0; i < label.Length; i++)
|
||||
{
|
||||
var ch = label[i];
|
||||
cleaned[i] = char.IsLetterOrDigit(ch) || ch is '.' or '_' or '-' or ' ' ? ch : '_';
|
||||
}
|
||||
|
||||
return new string(cleaned);
|
||||
}
|
||||
|
||||
private static string ShortTypeName(string typeName)
|
||||
{
|
||||
var idx = typeName.LastIndexOf('.');
|
||||
return idx >= 0 && idx < typeName.Length - 1 ? typeName[(idx + 1)..] : typeName;
|
||||
}
|
||||
|
||||
internal readonly struct ConfiguredCommand
|
||||
{
|
||||
public ConfiguredCommand(string label, string command, string declaringType,
|
||||
string obfuscatedDeclaringType, string methodName, bool hasRuntimeTarget)
|
||||
{
|
||||
Label = label;
|
||||
Command = command;
|
||||
DeclaringType = declaringType;
|
||||
ObfuscatedDeclaringType = obfuscatedDeclaringType;
|
||||
MethodName = methodName;
|
||||
HasRuntimeTarget = hasRuntimeTarget;
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
public string Command { get; }
|
||||
public string DeclaringType { get; }
|
||||
public string ObfuscatedDeclaringType { get; }
|
||||
public string MethodName { get; }
|
||||
public bool HasRuntimeTarget { get; }
|
||||
}
|
||||
|
||||
private static void EnableShowButton()
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
|
||||
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
|
||||
{
|
||||
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
|
||||
}
|
||||
}
|
||||
catch { /* best-effort — the toggle still works without it */ }
|
||||
}
|
||||
|
||||
private static object? ToIl2CppType(Type managed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppTypeType = DebugMod.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; }
|
||||
}
|
||||
|
||||
private static MethodInfo? OneArgMethod(Type? type, string name, object arg)
|
||||
{
|
||||
if (type is null) return null;
|
||||
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (method.Name != name) continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length == 1 && parameters[0].ParameterType.IsInstanceOfType(arg))
|
||||
return method;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ArrLen(object? array)
|
||||
{
|
||||
if (array is null) return 0;
|
||||
if (array is Array managedArray) return managedArray.Length;
|
||||
var type = array.GetType();
|
||||
var length = type.GetProperty("Length") ?? type.GetProperty("Count");
|
||||
return (length?.GetValue(array) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static object? ArrItem(object? array, int index)
|
||||
{
|
||||
if (array is null) return null;
|
||||
if (array is Array managedArray) return managedArray.GetValue(index);
|
||||
var getItem = array.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return getItem?.Invoke(array, new object[] { index });
|
||||
}
|
||||
}
|
||||
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;
|
||||
DebugMod.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;
|
||||
DebugMod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.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 AuthPatches
|
||||
{
|
||||
private static string? _latestAccessToken;
|
||||
private static int _tokenLogCount;
|
||||
|
||||
public static void CaptureTokenArgs_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var accessToken = __0?.ToString();
|
||||
if (LooksLikeJwt(accessToken))
|
||||
_latestAccessToken = accessToken;
|
||||
|
||||
if (_tokenLogCount++ < 3)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
"[auth-trace] token setter " +
|
||||
$"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[auth-trace] token capture failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LooksLikeJwt(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
|
||||
var dots = 0;
|
||||
foreach (var ch in value)
|
||||
if (ch == '.') dots++;
|
||||
return dots == 2;
|
||||
}
|
||||
|
||||
private static string Presence(object? value) =>
|
||||
string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present";
|
||||
}
|
||||
internal static class HttpTracePatches
|
||||
{
|
||||
private const int BodyPreviewBytes = 700;
|
||||
private static readonly Regex SensitivePairRegex = new(
|
||||
@"(?i)(password|client_secret|access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex BearerRegex = new(
|
||||
@"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex SensitiveHeaderRegex = new(
|
||||
@"(?im)^(Authorization|Cookie|Set-Cookie|X-RNSIG):\s*[^\r\n]+",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex SensitiveJsonStringRegex = new(
|
||||
@"(?i)""([^""]*(?:access_token|accessToken|refresh_token|refreshToken|authorization|token|secret|client_secret|key)[^""]*)""\s*:\s*""[^""]*""",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Dictionary<IntPtr, string> RecNetRequestInfoByPtr = new();
|
||||
private static int _requestId;
|
||||
private static int _failureLogCount;
|
||||
private static int _responseTupleLogCount;
|
||||
private static int _builderSendLogCount;
|
||||
private static int _callbackLogCount;
|
||||
private static int _uriUnreadDumpCount;
|
||||
|
||||
public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
TraceRequestObject(__instance, $"{__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||
}
|
||||
|
||||
public static void RecNetRequestBuilderCtor_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
var id = NextRequestId();
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-trace] #{id} BNDIAONDFFF.ctor method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
|
||||
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}");
|
||||
}
|
||||
|
||||
public static void RecNetRequestBuilderCtor_Postfix(object __instance, object? __0, object? __1, object? __2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(__instance);
|
||||
if (ptr == IntPtr.Zero) return;
|
||||
|
||||
var info =
|
||||
$"ptr=0x{ptr.ToInt64():X} method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
|
||||
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}";
|
||||
lock (RecNetRequestInfoByPtr)
|
||||
{
|
||||
RecNetRequestInfoByPtr[ptr] = info;
|
||||
if (RecNetRequestInfoByPtr.Count > 256)
|
||||
RecNetRequestInfoByPtr.Clear();
|
||||
}
|
||||
|
||||
DiagnosticPatches.Write($"[recnet-request-map] {info}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-request-map", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetBuilderSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (System.Threading.Interlocked.Increment(ref _builderSendLogCount) > 120) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-send] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-send-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetBuilderSend_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (_builderSendLogCount > 120) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-send] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-send-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetResponseTuple_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (System.Threading.Interlocked.Increment(ref _responseTupleLogCount) > 80) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-response-map-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetResponseTuple_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (_responseTupleLogCount > 80) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-response-map-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static Exception? RecNetResponseTuple_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
|
||||
$"state={DumpObjectFields(__instance, 36, 1200)}");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = __0?.ToString() ?? "<null>";
|
||||
var value = RedactHeaderValue(name, __1?.ToString());
|
||||
var uri = TryReadRequestUri(__instance)?.ToString() ?? "<uri unread>";
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-header] {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name} " +
|
||||
$"{SanitizeInline(uri)} {name}: {value}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("header", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CallCallback_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CallCallback_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ShouldLogCallback(__instance)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static Exception? CallCallback_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)} " +
|
||||
$"{RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
|
||||
$"state={DumpObjectFields(__instance, 32, 900)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-finalizer", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void TraceUrl(string? url, string source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var id = NextRequestId();
|
||||
DiagnosticPatches.Write($"[http-trace] #{id} {source} url={SanitizeInline(url)}");
|
||||
}
|
||||
|
||||
public static void TraceRequestObject(object? request, string source)
|
||||
{
|
||||
if (request is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var id = NextRequestId();
|
||||
var uriValue = TryReadRequestUri(request);
|
||||
var uri = uriValue?.ToString() ?? "<uri unread>";
|
||||
var method = TryReadHttpMethod(request);
|
||||
var headers = TryReadHeaders(request);
|
||||
var body = TryReadBodyPreview(request);
|
||||
var fallback = uriValue is null && System.Threading.Interlocked.Increment(ref _uriUnreadDumpCount) <= 40
|
||||
? $" requestFields={DumpObjectFields(request, 24, 260)}"
|
||||
: "";
|
||||
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " +
|
||||
$"headers={headers} body={body}{fallback}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure(source, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static int NextRequestId() => System.Threading.Interlocked.Increment(ref _requestId);
|
||||
|
||||
private static Uri? TryReadRequestUri(object request)
|
||||
{
|
||||
if (TryInvokeUriMethod(request, "KPKNHFBHPHO", out var uri)) return uri;
|
||||
if (TryInvokeUriMethod(request, "IBLHNHHBNNE", out uri)) return uri;
|
||||
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
var type = request.GetType();
|
||||
|
||||
if (TryReadUriValue(type.GetProperty("Uri", flags)?.GetValue(request), out uri)) return uri;
|
||||
if (TryReadUriValue(type.GetProperty("CurrentUri", flags)?.GetValue(request), out uri)) return uri;
|
||||
if (TryReadUriValue(type.GetField("<Uri>k__BackingField", flags)?.GetValue(request), out uri)) return uri;
|
||||
|
||||
foreach (var prop in type.GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try
|
||||
{
|
||||
if (TryReadUriValue(prop.GetValue(request), out uri)) return uri;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
try
|
||||
{
|
||||
if (TryReadUriValue(field.GetValue(request), out uri)) return uri;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryInvokeUriMethod(object request, string methodName, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
|
||||
if (method is null) return false;
|
||||
return TryReadUriValue(method.Invoke(request, Array.Empty<object>()), out uri);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadUriValue(object? value, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
if (value is null) return false;
|
||||
if (value is Uri direct)
|
||||
{
|
||||
uri = direct;
|
||||
return true;
|
||||
}
|
||||
|
||||
var text = value.ToString();
|
||||
if (Uri.TryCreate(text, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
uri = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string TryReadHttpMethod(object request)
|
||||
{
|
||||
foreach (var name in new[] { "MethodType", "Method", "LNJILMKOBIP" })
|
||||
{
|
||||
if (TryReadMemberValue(request, name, out var value))
|
||||
return SanitizeInline(value?.ToString() ?? "<null>");
|
||||
}
|
||||
|
||||
return "<method unread>";
|
||||
}
|
||||
|
||||
private static string TryReadHeaders(object request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dump = AccessTools.Method(request.GetType(), "DumpHeaders", Type.EmptyTypes);
|
||||
if (dump?.Invoke(request, Array.Empty<object>()) is object rawDump)
|
||||
return SanitizeInline(rawDump.ToString() ?? "<empty>");
|
||||
}
|
||||
catch { }
|
||||
|
||||
var known = new List<string>();
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"Authorization", "Cookie", "X-RNSIG", "Accept-Language",
|
||||
"Content-Type", "User-Agent"
|
||||
})
|
||||
{
|
||||
var value = TryReadHeaderValue(request, name);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
known.Add($"{name}:{RedactHeaderValue(name, value)}");
|
||||
}
|
||||
|
||||
return known.Count == 0 ? "<headers unread>" : string.Join("; ", known);
|
||||
}
|
||||
|
||||
private static string? TryReadHeaderValue(object request, string name)
|
||||
{
|
||||
foreach (var methodName in new[] { "GetFirstHeaderValue", "FKHEHIMHHBP" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, new[] { typeof(string) });
|
||||
var value = method?.Invoke(request, new object[] { name })?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string TryReadBodyPreview(object request)
|
||||
{
|
||||
foreach (var methodName in new[] { "BGMFJOKNJAJ", "GetEntityBody" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
|
||||
if (method is null) continue;
|
||||
var body = method.Invoke(request, Array.Empty<object>());
|
||||
var bytes = TryCopyBytes(body, BodyPreviewBytes + 1);
|
||||
if (bytes is null) continue;
|
||||
if (bytes.Length == 0) return "<empty>";
|
||||
|
||||
var shown = Math.Min(bytes.Length, BodyPreviewBytes);
|
||||
var text = Encoding.UTF8.GetString(bytes, 0, shown);
|
||||
var suffix = bytes.Length > BodyPreviewBytes ? "...<truncated>" : "";
|
||||
return $"{bytes.Length} bytes \"{SanitizeInline(text)}{suffix}\"";
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return "<body unread>";
|
||||
}
|
||||
|
||||
private static bool ShouldLogCallback(object request)
|
||||
{
|
||||
var response = TryReadResponseObject(request);
|
||||
var status = response is null ? null : TryReadStatusCode(response);
|
||||
if (status >= 400) return true;
|
||||
return System.Threading.Interlocked.Increment(ref _callbackLogCount) <= 80;
|
||||
}
|
||||
|
||||
private static string DescribeRequestAndResponse(object request)
|
||||
{
|
||||
var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>";
|
||||
var method = TryReadHttpMethod(request);
|
||||
var response = TryReadResponseObject(request);
|
||||
var responseText = response is null ? "<response unread>" : DescribeResponseObject(response);
|
||||
var requestState = DescribeKnownMembers(request, "State", "Exception", "Error", "IsCancellationRequested");
|
||||
return $"method={method} uri={SanitizeInline(uri)} request={requestState} response={responseText}";
|
||||
}
|
||||
|
||||
private static object? TryReadResponseObject(object request)
|
||||
{
|
||||
foreach (var name in new[] { "Response", "response", "_response", "<Response>k__BackingField" })
|
||||
{
|
||||
if (TryReadMemberValue(request, name, out var value) && value is not null)
|
||||
return value;
|
||||
}
|
||||
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var prop in request.GetType().GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try
|
||||
{
|
||||
var value = prop.GetValue(request);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
foreach (var field in request.GetType().GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
try
|
||||
{
|
||||
var value = field.GetValue(request);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? TryReadStatusCode(object response)
|
||||
{
|
||||
if (TryReadMemberValue(response, "StatusCode", out var value))
|
||||
{
|
||||
try { return Convert.ToInt32(value); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string DescribeResponseObject(object response)
|
||||
{
|
||||
var known = DescribeKnownMembers(response,
|
||||
"StatusCode", "Message", "IsSuccess", "DataAsText", "Data", "Exception", "Error");
|
||||
return $"{response.GetType().FullName} {{{known}}}";
|
||||
}
|
||||
|
||||
private static string DescribeKnownMembers(object obj, params string[] names)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryReadMemberValue(obj, name, out var value)) continue;
|
||||
parts.Add($"{name}={FormatValue(value, 700)}");
|
||||
}
|
||||
return parts.Count == 0 ? "<no known members>" : string.Join("; ", parts);
|
||||
}
|
||||
|
||||
private static string FormatValue(object? value, int limit)
|
||||
{
|
||||
if (value is null) return "null";
|
||||
if (value is byte[] bytes)
|
||||
return $"byte[{bytes.Length}]";
|
||||
if (value is IEnumerable enumerable && value is not string)
|
||||
return FormatEnumerable(enumerable, value.GetType(), limit);
|
||||
var text = value is Exception ex
|
||||
? $"{ex.GetType().Name}: {ex.Message}"
|
||||
: value.ToString() ?? "<null>";
|
||||
text = SanitizeInline(text);
|
||||
return text.Length <= limit ? text : text[..limit] + "...<truncated>";
|
||||
}
|
||||
|
||||
private static string FormatEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var countText = TryReadEnumerableCount(enumerable, out var count) ? count.ToString() : "?";
|
||||
var shown = 0;
|
||||
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (shown++ >= 6)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
parts.Add(FormatValue(item, 180));
|
||||
}
|
||||
|
||||
var rendered = $"{type.FullName} count={countText} [{string.Join(", ", parts)}]";
|
||||
return rendered.Length <= limit ? rendered : rendered[..limit] + "...<truncated>";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"{type.FullName} <enumeration failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadEnumerableCount(IEnumerable enumerable, out int count)
|
||||
{
|
||||
count = 0;
|
||||
if (enumerable is ICollection collection)
|
||||
{
|
||||
count = collection.Count;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var prop = enumerable.GetType().GetProperty("Count", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop?.GetValue(enumerable) is int value)
|
||||
{
|
||||
count = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static byte[]? TryCopyBytes(object? value, int limit)
|
||||
{
|
||||
if (value is null) return null;
|
||||
if (value is byte[] direct)
|
||||
return CopyLimited(direct, limit);
|
||||
|
||||
if (value is IEnumerable enumerable)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (item is null) continue;
|
||||
try { bytes.Add(Convert.ToByte(item)); }
|
||||
catch { return null; }
|
||||
if (bytes.Count >= limit) break;
|
||||
}
|
||||
return bytes.ToArray();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] CopyLimited(byte[] source, int limit)
|
||||
{
|
||||
var count = Math.Min(source.Length, limit);
|
||||
var copy = new byte[count];
|
||||
Array.Copy(source, copy, count);
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static bool TryReadMemberValue(object request, string name, out object? value)
|
||||
{
|
||||
value = null;
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
var type = request.GetType();
|
||||
|
||||
try
|
||||
{
|
||||
var prop = type.GetProperty(name, flags);
|
||||
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
|
||||
{
|
||||
value = prop.GetValue(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var field = type.GetField(name, flags);
|
||||
if (field is not null)
|
||||
{
|
||||
value = field.GetValue(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(type, name, Type.EmptyTypes);
|
||||
if (method is not null)
|
||||
{
|
||||
value = method.Invoke(request, Array.Empty<object>());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string DumpObjectFields(object? instance, int maxFields, int valueLimit)
|
||||
{
|
||||
if (instance is null) return "<null>";
|
||||
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? value;
|
||||
try { value = field.GetValue(instance); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{field.Name}={FormatValue(value, valueLimit)}");
|
||||
}
|
||||
|
||||
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<field dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string LookupRecNetRequestInfo(object instance)
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(instance);
|
||||
if (ptr == IntPtr.Zero) return "ptr=<unread>";
|
||||
|
||||
lock (RecNetRequestInfoByPtr)
|
||||
{
|
||||
return RecNetRequestInfoByPtr.TryGetValue(ptr, out var info)
|
||||
? info
|
||||
: $"ptr=0x{ptr.ToInt64():X} request=<unmapped>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DumpBndNativeFields(object? instance)
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(instance);
|
||||
if (ptr == IntPtr.Zero) return "native=<unread>";
|
||||
|
||||
try
|
||||
{
|
||||
var method = Marshal.ReadByte(ptr, 16);
|
||||
var service = Marshal.ReadInt32(ptr, 20);
|
||||
var pathPtr = Marshal.ReadIntPtr(ptr, 24);
|
||||
var fieldsPtr = Marshal.ReadIntPtr(ptr, 32);
|
||||
var filesPtr = Marshal.ReadIntPtr(ptr, 40);
|
||||
var objectFieldsPtr = Marshal.ReadIntPtr(ptr, 48);
|
||||
var bodyKind = Marshal.ReadInt32(ptr, 56);
|
||||
var callbackPtr = Marshal.ReadIntPtr(ptr, 64);
|
||||
var path = ReadIl2CppString(pathPtr);
|
||||
|
||||
return
|
||||
"native={" +
|
||||
$"method={method}; service={service}; path={SanitizeInline(path)}; " +
|
||||
$"pathPtr=0x{pathPtr.ToInt64():X}; fieldsPtr=0x{fieldsPtr.ToInt64():X}; " +
|
||||
$"filesPtr=0x{filesPtr.ToInt64():X}; objectFieldsPtr=0x{objectFieldsPtr.ToInt64():X}; " +
|
||||
$"bodyKind={bodyKind}; callbackPtr=0x{callbackPtr.ToInt64():X}" +
|
||||
"}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"native=<dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadIl2CppString(IntPtr ptr)
|
||||
{
|
||||
if (ptr == IntPtr.Zero) return "<null>";
|
||||
|
||||
try
|
||||
{
|
||||
return IL2CPP.Il2CppStringToManaged(ptr) ?? "<null>";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<string read failed 0x{ptr.ToInt64():X} {ex.GetType().Name}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr TryGetIl2CppPtr(object? instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
return instance is Il2CppObjectBase il2CppObject
|
||||
? IL2CPP.Il2CppObjectBaseToPtrNotNull(il2CppObject)
|
||||
: IntPtr.Zero;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RedactHeaderValue(string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "<empty>";
|
||||
if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("X-RNSIG", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "<redacted>";
|
||||
}
|
||||
|
||||
return SanitizeInline(value);
|
||||
}
|
||||
|
||||
private static string SanitizeInline(string value)
|
||||
{
|
||||
var redacted = SensitivePairRegex.Replace(value, "$1=<redacted>");
|
||||
redacted = BearerRegex.Replace(redacted, "Bearer <redacted>");
|
||||
redacted = SensitiveHeaderRegex.Replace(redacted, "$1: <redacted>");
|
||||
redacted = SensitiveJsonStringRegex.Replace(redacted, "\"$1\":\"<redacted>\"");
|
||||
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
return redacted.Length <= 1200 ? redacted : redacted[..1200] + "...<truncated>";
|
||||
}
|
||||
|
||||
private static void LogFailure(string source, Exception ex)
|
||||
{
|
||||
if (_failureLogCount++ < 10)
|
||||
DebugMod.Log.Warning($"[http-trace] {source} failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
internal static class SavePatches
|
||||
{
|
||||
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
|
||||
// our currently-loaded room. Captured BEFORE the watch's caching
|
||||
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
|
||||
// (roomId, subRoomId) tuple — which is every post-save push for a
|
||||
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
|
||||
// would-be no-op setter call into a real transition.
|
||||
private static volatile string? s_lastPushedBlobName;
|
||||
private static long s_lastPushedRoomId;
|
||||
private static long s_lastPushedSubRoomId;
|
||||
|
||||
// Hook on Notifications.OnNotificationReceived(string json). Fires for
|
||||
// every SignalR message before any handler dispatch. Parses the JSON
|
||||
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
|
||||
public static void OnNotificationReceived_Prefix(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
// Fast reject: only parse JSON if it could plausibly be a
|
||||
// SubscriptionUpdateRoom payload. Saves cycles on the (very
|
||||
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
|
||||
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
|
||||
return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!root.TryGetProperty("Id", out var idEl) ||
|
||||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
|
||||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
|
||||
return;
|
||||
if (!root.TryGetProperty("Msg", out var msg) ||
|
||||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!msg.TryGetProperty("Room", out var room) ||
|
||||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
long roomId = 0;
|
||||
if (room.TryGetProperty("RoomId", out var ridEl) &&
|
||||
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
roomId = ridEl.GetInt64();
|
||||
if (!msg.TryGetProperty("Scenes", out var scenes) ||
|
||||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
|
||||
scenes.GetArrayLength() == 0) return;
|
||||
var first = scenes[0];
|
||||
long subRoomId = 0;
|
||||
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
|
||||
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
subRoomId = sidEl.GetInt64();
|
||||
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
|
||||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
|
||||
var blob = blobEl.GetString();
|
||||
if (string.IsNullOrEmpty(blob)) return;
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = blob;
|
||||
DebugMod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void OnSubscriptionUpdateRoom_Prefix(object message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message is null) return;
|
||||
var dict = message;
|
||||
|
||||
// The watch parses this dict as Dictionary<string, object>
|
||||
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
|
||||
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
|
||||
// get_Item(key) — call it via reflection.
|
||||
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (getItem is null) return;
|
||||
|
||||
// The push wire shape from server (BuildRoomDetails) is
|
||||
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
|
||||
// on the Room sub-object; the scene's blob on Scenes[0].
|
||||
object? roomObj = TryGet(dict, getItem, "Room");
|
||||
object? scenesObj = TryGet(dict, getItem, "Scenes");
|
||||
if (roomObj is null || scenesObj is null) return;
|
||||
|
||||
long roomId = 0;
|
||||
long subRoomId = 0;
|
||||
try
|
||||
{
|
||||
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (roomItem is not null)
|
||||
{
|
||||
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
|
||||
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* roomId stays 0 — we'll log it and bail later */ }
|
||||
|
||||
// Scenes is an IL2CPP List<RoomScene>. Take the first
|
||||
// (current dorm has one scene; multi-scene rooms target
|
||||
// Scenes[currentSubRoomId] specifically, but the post-save
|
||||
// push always carries the scene that was just saved).
|
||||
object? firstScene = null;
|
||||
try
|
||||
{
|
||||
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
|
||||
}
|
||||
catch { /* leave firstScene null */ }
|
||||
if (firstScene is null) return;
|
||||
|
||||
string? newBlob = null;
|
||||
try
|
||||
{
|
||||
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (sceneItem is not null)
|
||||
{
|
||||
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
|
||||
newBlob = blob?.ToString();
|
||||
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
|
||||
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* leave newBlob null */ }
|
||||
|
||||
if (string.IsNullOrEmpty(newBlob)) return;
|
||||
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = newBlob;
|
||||
DebugMod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object? TryGet(object dict, MethodInfo getItem, string key)
|
||||
{
|
||||
try { return getItem.Invoke(dict, new object[] { key }); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
|
||||
// path only when the new value differs from the cached field. The
|
||||
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
|
||||
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
|
||||
// save it calls the setter with the OLD blob — a no-op that skips
|
||||
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
|
||||
// pointed at the boot-time bytes, and PostSaveReloading deserializes
|
||||
// those over the live world — wiping any MakerPen object the user
|
||||
// just placed.
|
||||
//
|
||||
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
|
||||
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
|
||||
// after b__4 returns True, even though the SubscriptionUpdateRoom
|
||||
// push already carried Scenes[0].DataBlobName="v11".
|
||||
//
|
||||
// Strategy: intercept the setter. If the incoming value matches the
|
||||
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
|
||||
// newer version (the freshly-pushed one), substitute that into the
|
||||
// value parameter so the real setter sees a transition and fires
|
||||
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
|
||||
// Falls through silently when no override is needed.
|
||||
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
|
||||
{
|
||||
string currentForLog = "<unread>";
|
||||
string sceneForLog = "<unread>";
|
||||
string scenes0ForLog = "<unread>";
|
||||
try
|
||||
{
|
||||
if (__instance is null)
|
||||
{
|
||||
DebugMod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var rpmType = __instance.GetType();
|
||||
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (blobNameProp is null)
|
||||
{
|
||||
DebugMod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
|
||||
return;
|
||||
}
|
||||
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
|
||||
|
||||
var roomsType = FindGameType("RecNet.Rooms");
|
||||
if (roomsType is null)
|
||||
{
|
||||
DebugMod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var localScene = roomsType.GetProperty("LocalRoomScene",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localScene is not null)
|
||||
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
|
||||
|
||||
var localDetails = roomsType.GetProperty("LocalRoomDetails",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localDetails is not null)
|
||||
{
|
||||
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
|
||||
if (scenes is not null)
|
||||
{
|
||||
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
var first = indexer?.Invoke(scenes, new object[] { 0 });
|
||||
if (first is not null)
|
||||
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional trace of EVERYTHING we read so we can see in
|
||||
// the log exactly why we did or didn't override.
|
||||
DebugMod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
|
||||
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
|
||||
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
|
||||
|
||||
// Pick a candidate "newer" blob name. Priority order:
|
||||
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
|
||||
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
|
||||
// cache gate drops the push).
|
||||
// 2. LocalRoomScene.DataBlobName.
|
||||
// 3. Scenes[0].DataBlobName.
|
||||
string? pushedBlob = s_lastPushedBlobName;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
|
||||
|
||||
if (string.IsNullOrEmpty(pushedBlob)) return;
|
||||
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
|
||||
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
|
||||
|
||||
DebugMod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
|
||||
value = pushedBlob;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
|
||||
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
|
||||
}
|
||||
}
|
||||
|
||||
private static Type? FindGameType(string name) => DebugMod.ResolveType(name);
|
||||
}
|
||||
internal static class QuitTracePatches
|
||||
{
|
||||
public static bool ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit-trace] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit-trace] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
if (exitCode == 1550502249 || exitCode == 533223478)
|
||||
{
|
||||
Write($"[quit-blocked] UnityEngine.Application.Quit fatal client exit {exitCode}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP}, {EPAIMCEIMPA})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ApplicationWantsToQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)} wants-to-quit handler entered");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void CreateExitTask_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)} creating exit task");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
private static string FormatOriginal(MethodBase method)
|
||||
{
|
||||
return $"{method.DeclaringType?.FullName}.{method.Name}";
|
||||
}
|
||||
|
||||
private static void Write(string message)
|
||||
{
|
||||
try { DebugMod.Log.Msg(message); } catch { }
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[quit-trace-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[quit-trace-stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
internal static class DiagnosticPatches
|
||||
{
|
||||
public static void Write(string message)
|
||||
{
|
||||
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
|
||||
try { DebugMod.Log.Msg(line); } catch { }
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(DebugMod.DiagnosticsLogPath))
|
||||
File.AppendAllText(DebugMod.DiagnosticsLogPath, line + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static bool ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void NamedMethod_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP}, {EPAIMCEIMPA})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
internal static class ChatPatches
|
||||
{
|
||||
private static bool s_logged;
|
||||
|
||||
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
|
||||
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
|
||||
// before raising the in-room Photon chat RPC; if it returns false
|
||||
// the coroutine bails silently and the sender doesn't even see
|
||||
// their own message echo.
|
||||
public static void CanLocalPlayerChat_Postfix(ref bool __result)
|
||||
{
|
||||
if (!__result && !s_logged)
|
||||
{
|
||||
s_logged = true;
|
||||
DebugMod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
|
||||
}
|
||||
__result = true;
|
||||
}
|
||||
|
||||
// Skip the original. Returning false from a Harmony prefix stops
|
||||
// the underlying call.
|
||||
public static bool RemoveInvalidCharactersFromMessage_Prefix()
|
||||
{
|
||||
DebugMod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void SendChatEmote_Prefix(string message)
|
||||
{
|
||||
DebugMod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
|
||||
}
|
||||
|
||||
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
|
||||
{
|
||||
DebugMod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
|
||||
}
|
||||
|
||||
public static void RpcChatEmote_Prefix(string message)
|
||||
{
|
||||
DebugMod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
|
||||
|
||||
The shipping ClientMod carries ONLY the patches needed to connect and
|
||||
survive anti-cheat. ALL of the verbose diagnostic/tracing code (HTTP
|
||||
tracing, join/quit/studio/room-load traces, the process-exception sink,
|
||||
auth-token tracing, the registration-dialog trace, the in-game
|
||||
DebugConsole enable path) lives in THIS mod. Drop this dll into the
|
||||
Mods folder next to DorkNet.ClientMod.dll to debug; remove it to ship a
|
||||
quiet, minimal client.
|
||||
|
||||
Fully self-contained: its own Harmony instance + its own copy of the
|
||||
reflection/patch helpers, so it never couples to ClientMod at load time.
|
||||
Debug knobs are read from the same UserData\dorknet-clientmod.json.
|
||||
|
||||
Build: dotnet build DorkNet.DebugMod/DorkNet.DebugMod.csproj -c Release
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.DebugMod</RootNamespace>
|
||||
<AssemblyName>DorkNet.DebugMod</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the MelonLoader the ClientMod build already downloads. -->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader\MelonLoader\net6</MelonLoaderDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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 ===");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<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>
|
||||
@@ -0,0 +1,351 @@
|
||||
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();
|
||||
|
||||
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 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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Standalone MelonLoader IL2CPP mod whose ONLY job is to dump every
|
||||
Google.Protobuf FileDescriptor that the running client has loaded.
|
||||
|
||||
It carries no Harmony patches, so it is safe to drop onto ANY Rec Room
|
||||
build (2020, 2023, …) without the version-specific ClientMod patches
|
||||
misfiring. Everything game-side is reached through plain
|
||||
System.Reflection over the Il2CppInterop proxy assemblies, so no game
|
||||
DLLs are referenced at compile time (they would be derivative works).
|
||||
|
||||
Build (desktop net6 MelonLoader, same pin as the ClientMod):
|
||||
|
||||
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||
|
||||
Drop bin\Release\DorkNet.ProtoDumper.dll into <RecRoom>\Mods\ on a
|
||||
MelonLoader-patched build and launch. Output lands in
|
||||
<RecRoom>\DorkNet-ProtoDump\.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.ProtoDumper</RootNamespace>
|
||||
<AssemblyName>DorkNet.ProtoDumper</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the MelonLoader DLLs the ClientMod project already downloads. -->
|
||||
<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>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\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)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,378 @@
|
||||
// DorkNet ProtoDumper — runtime Google.Protobuf schema extractor.
|
||||
//
|
||||
// Rec Room compiles its room-save / creation / circuits data model from
|
||||
// .proto files into Google.Protobuf C# classes. In the shipped IL2CPP build
|
||||
// those classes are name-obfuscated and the descriptor base64 is split into
|
||||
// scattered string-literal chunks, so static reconstruction from
|
||||
// global-metadata.dat is unreliable.
|
||||
//
|
||||
// At RUNTIME every FileDescriptor still carries its original, un-obfuscated
|
||||
// FileDescriptorProto (message names, field names, numbers, types intact).
|
||||
// The catch: these descriptors are built LAZILY and in a specific dependency
|
||||
// order by the game. Force-building them ourselves (by reading a message
|
||||
// type's static descriptor property out of order) throws inside the type's
|
||||
// static constructor and the failure is cached — poisoning the type. So we do
|
||||
// NOT enumerate types. Instead we Harmony-postfix the one library method every
|
||||
// generated cctor funnels through — Google.Protobuf.Reflection.FileDescriptor
|
||||
// .BuildFrom / .FromGeneratedCode — and capture each descriptor as the game
|
||||
// builds it. Completely passive: just play the client through the dorm, a
|
||||
// created room, the Maker Pen and a circuits board, and every descriptor that
|
||||
// loads is recorded.
|
||||
//
|
||||
// Output (under <RecRoom>\DorkNet-ProtoDump\):
|
||||
// manifest.tsv — one "<proto path>\t<base64>" line per file
|
||||
// files\<sanitised>.b64 — same base64, one file each
|
||||
// dump.log — capture log
|
||||
//
|
||||
// Decode offline with decode_dump.py (see README.md).
|
||||
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ProtoDumper.ProtoDumper), "DorkNet ProtoDumper", "2.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.ProtoDumper;
|
||||
|
||||
public class ProtoDumper : MelonMod
|
||||
{
|
||||
private static MelonLogger.Instance? _log;
|
||||
|
||||
// proto file name -> descriptor base64 (accumulated as the game builds them)
|
||||
private static readonly Dictionary<string, string> Files = new(StringComparer.Ordinal);
|
||||
private static readonly object Gate = new();
|
||||
private static string _outDir = "";
|
||||
private static string _filesDir = "";
|
||||
private static volatile bool _dirty;
|
||||
private int _frame;
|
||||
|
||||
// Interception heartbeat: how many times any hooked method actually ran.
|
||||
// Lets us distinguish "hook never fires (inlined/wrong target)" from
|
||||
// "content that builds protos hasn't loaded yet".
|
||||
private static long _fireCount;
|
||||
private static bool _firstFireLogged;
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
_log = LoggerInstance;
|
||||
try
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory ?? Directory.GetCurrentDirectory();
|
||||
_outDir = Path.Combine(baseDir, "DorkNet-ProtoDump");
|
||||
_filesDir = Path.Combine(_outDir, "files");
|
||||
Directory.CreateDirectory(_filesDir);
|
||||
_log.Msg($"ProtoDumper ready. Output dir: {_outDir}");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper init failed: {e}"); }
|
||||
|
||||
InstallHooks();
|
||||
}
|
||||
|
||||
private void InstallHooks()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Google.Protobuf library type — NOT obfuscated (it's the library,
|
||||
// not game code), reached by its original name via every loaded
|
||||
// assembly so we never reference the interop DLL at compile time.
|
||||
Type? fd = ResolveType("Google.Protobuf.Reflection.FileDescriptor");
|
||||
if (fd == null)
|
||||
{
|
||||
_log?.Error("ProtoDumper: FileDescriptor type not found — cannot hook.");
|
||||
return;
|
||||
}
|
||||
|
||||
var postfix = new HarmonyMethod(
|
||||
typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix),
|
||||
BindingFlags.Public | BindingFlags.Static));
|
||||
var ctorPostfix = new HarmonyMethod(
|
||||
typeof(ProtoDumper).GetMethod(nameof(Ctor_Postfix),
|
||||
BindingFlags.Public | BindingFlags.Static));
|
||||
|
||||
int patched = 0;
|
||||
|
||||
// PRIMARY hook: the 6-arg instance constructor. Every FileDescriptor
|
||||
// is built via `newobj` through this ctor, which is NOT an inlining
|
||||
// candidate the way the small static BuildFrom/FromGeneratedCode
|
||||
// helpers are (those get AOT-inlined into each generated cctor, so a
|
||||
// Harmony patch on the managed wrapper never fires — the real work
|
||||
// runs as inlined native code). The ctor stores SerializedData /
|
||||
// Proto / Name before it returns, so __instance carries the full
|
||||
// descriptor bytes.
|
||||
foreach (ConstructorInfo c in fd.GetConstructors(
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
var ps = c.GetParameters();
|
||||
// Skip the Il2CppInterop IntPtr wrapper ctor (single IntPtr arg).
|
||||
if (ps.Length == 1 && ps[0].ParameterType == typeof(IntPtr)) continue;
|
||||
try { HarmonyInstance.Patch(c, postfix: ctorPostfix); patched++; _log?.Msg($"ProtoDumper: hooked .ctor({ps.Length} args)"); }
|
||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch .ctor({ps.Length}) failed: {e.Message}"); }
|
||||
}
|
||||
|
||||
// SECONDARY hooks (kept as a belt-and-braces net in case the ctor is
|
||||
// itself inlined on some build): BuildFrom / FromGeneratedCode.
|
||||
foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" })
|
||||
{
|
||||
foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != name) continue;
|
||||
try { HarmonyInstance.Patch(m, postfix: postfix); patched++; _log?.Msg($"ProtoDumper: hooked {name}({m.GetParameters().Length} args)"); }
|
||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// STRONGEST hook: DescriptorPool.AddSymbol(IDescriptor). Called once
|
||||
// per message/enum/service symbol while a descriptor is built. Unlike
|
||||
// the tiny BuildFrom/FromGeneratedCode/ctor helpers, AddSymbol does
|
||||
// real work (name validation + dictionary insert + duplicate-throw),
|
||||
// so the AOT compiler does NOT inline it away — the managed patch
|
||||
// actually fires. From the symbol we walk `.File` to the owning
|
||||
// FileDescriptor and collect its serialized bytes. We also stash the
|
||||
// pool so ScanPools() can later enumerate everything it holds.
|
||||
Type? pool = ResolveType("Google.Protobuf.Reflection.DescriptorPool");
|
||||
if (pool != null)
|
||||
{
|
||||
var symPostfix = new HarmonyMethod(
|
||||
typeof(ProtoDumper).GetMethod(nameof(AddSymbol_Postfix),
|
||||
BindingFlags.Public | BindingFlags.Static));
|
||||
foreach (MethodInfo m in pool.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
if (m.Name != "AddSymbol" && m.Name != "AddPackage") continue;
|
||||
try { HarmonyInstance.Patch(m, postfix: symPostfix); patched++; _log?.Msg($"ProtoDumper: hooked DescriptorPool.{m.Name}({m.GetParameters().Length} args)"); }
|
||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch DescriptorPool.{m.Name} failed: {e.Message}"); }
|
||||
}
|
||||
}
|
||||
else _log?.Warning("ProtoDumper: DescriptorPool type not found — AddSymbol hook skipped.");
|
||||
|
||||
_log?.Msg($"ProtoDumper: installed {patched} descriptor hooks. Play through your content; descriptors are captured as they load. Watch for 'FIRST DESCRIPTOR CAPTURED'.");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper hook install error: {e}"); }
|
||||
}
|
||||
|
||||
// Resolve a type by its ORIGINAL (game-side) name, accounting for the
|
||||
// Il2Cpp prefix Il2CppInterop puts on the namespace (so
|
||||
// "Google.Protobuf.Reflection.FileDescriptor" ->
|
||||
// "Il2CppGoogle.Protobuf.Reflection.FileDescriptor").
|
||||
private static Type? ResolveType(string name)
|
||||
{
|
||||
foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, false) ?? asm.GetType("Il2Cpp" + name, false);
|
||||
if (t != null) return t;
|
||||
int dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var ns = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), false);
|
||||
if (ns != null) return ns;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Harmony postfix on FileDescriptor.BuildFrom / FromGeneratedCode.
|
||||
// __result is the freshly-built FileDescriptor (Il2Cpp proxy, boxed as
|
||||
// object — same pattern the ClientMod uses for il2cpp reference returns).
|
||||
public static void Captured_Postfix(object? __result)
|
||||
{
|
||||
Fired();
|
||||
if (__result == null) return;
|
||||
try { CollectFile(__result); }
|
||||
catch { /* never let our hook disturb the game */ }
|
||||
}
|
||||
|
||||
// Postfix on the FileDescriptor instance ctor — __instance is the freshly
|
||||
// constructed descriptor (SerializedData/Proto/Name already set).
|
||||
public static void Ctor_Postfix(object? __instance)
|
||||
{
|
||||
Fired();
|
||||
if (__instance == null) return;
|
||||
try { CollectFile(__instance); }
|
||||
catch { /* never let our hook disturb the game */ }
|
||||
}
|
||||
|
||||
// Live DescriptorPool references seen via AddSymbol, kept so ScanPools() can
|
||||
// enumerate every descriptor a pool holds (already-built → no force-build).
|
||||
private static readonly List<object> Pools = new();
|
||||
|
||||
// Postfix on DescriptorPool.AddSymbol / AddPackage. __instance is the pool,
|
||||
// __0 is the IDescriptor symbol being registered. Walk symbol.File to the
|
||||
// owning FileDescriptor and collect it.
|
||||
public static void AddSymbol_Postfix(object? __instance, object? __0)
|
||||
{
|
||||
Fired();
|
||||
try
|
||||
{
|
||||
if (__instance != null)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
bool known = false;
|
||||
foreach (var p in Pools) if (ReferenceEquals(p, __instance)) { known = true; break; }
|
||||
if (!known) Pools.Add(__instance);
|
||||
}
|
||||
}
|
||||
if (__0 == null) return;
|
||||
object? file = GetProp(__0, "File");
|
||||
if (file != null) CollectFile(file);
|
||||
}
|
||||
catch { /* never let our hook disturb the game */ }
|
||||
}
|
||||
|
||||
// Interception heartbeat — proves a hooked method actually ran. Logged once
|
||||
// on first fire, so "no output" can be diagnosed as inlining (never fires)
|
||||
// vs. content-not-loaded (fires but no proto-building content yet).
|
||||
private static void Fired()
|
||||
{
|
||||
long n = System.Threading.Interlocked.Increment(ref _fireCount);
|
||||
if (!_firstFireLogged)
|
||||
{
|
||||
_firstFireLogged = true;
|
||||
_log?.Msg("ProtoDumper: FIRST DESCRIPTOR CAPTURED — interception is live.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectFile(object file)
|
||||
{
|
||||
string? name = GetProp(file, "Name") as string;
|
||||
if (string.IsNullOrEmpty(name)) return;
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (Files.ContainsKey(name!)) return;
|
||||
|
||||
string b64 = DescriptorBase64(file);
|
||||
Files[name!] = b64;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
// Capture import dependencies too (closure of well-known + leaf protos).
|
||||
try
|
||||
{
|
||||
object? deps = GetProp(file, "Dependencies");
|
||||
if (deps != null)
|
||||
{
|
||||
var dt = deps.GetType();
|
||||
var countP = dt.GetProperty("Count");
|
||||
var itemM = dt.GetMethod("get_Item", new[] { typeof(int) });
|
||||
if (countP != null && itemM != null)
|
||||
{
|
||||
int n = (int)(countP.GetValue(deps) ?? 0);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
object? dep = itemM.Invoke(deps, new object[] { i });
|
||||
if (dep != null) CollectFile(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string DescriptorBase64(object file)
|
||||
{
|
||||
try
|
||||
{
|
||||
// FileDescriptor exposes no public SerializedData property — only
|
||||
// the Il2CppInterop backing field (a ByteString with the original
|
||||
// descriptor bytes). Fall back to re-serialising the parsed Proto.
|
||||
object? sd = GetProp(file, "_SerializedData_k__BackingField")
|
||||
?? GetProp(file, "SerializedData");
|
||||
if (sd != null)
|
||||
{
|
||||
string b = ByteStringToBase64(sd);
|
||||
if (b.Length > 0) return b;
|
||||
}
|
||||
object? proto = GetProp(file, "Proto");
|
||||
if (proto != null)
|
||||
{
|
||||
MethodInfo? toBs = proto.GetType().GetMethod("ToByteString", Type.EmptyTypes);
|
||||
object? bs = toBs?.Invoke(proto, null);
|
||||
if (bs != null) return ByteStringToBase64(bs);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string ByteStringToBase64(object byteString)
|
||||
{
|
||||
MethodInfo? toB64 = byteString.GetType().GetMethod("ToBase64", Type.EmptyTypes);
|
||||
return toB64?.Invoke(byteString, null) as string ?? "";
|
||||
}
|
||||
|
||||
private static object? GetProp(object obj, string propName)
|
||||
{
|
||||
var p = obj.GetType().GetProperty(propName,
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
return p?.GetValue(obj);
|
||||
}
|
||||
|
||||
private long _lastReportedFire = -1;
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
// Flush to disk shortly after new descriptors are captured (every ~1s).
|
||||
if (++_frame % 60 == 0 && _dirty)
|
||||
Flush();
|
||||
|
||||
// Every ~5s, if the hooks fired since last report, log the running
|
||||
// fire/capture counts — surfaces "fired but 0 files" (empty base64)
|
||||
// separately from "never fired".
|
||||
if (_frame % 300 == 0)
|
||||
{
|
||||
long f = System.Threading.Interlocked.Read(ref _fireCount);
|
||||
if (f != _lastReportedFire)
|
||||
{
|
||||
_lastReportedFire = f;
|
||||
int files; lock (Gate) files = Files.Count;
|
||||
_log?.Msg($"ProtoDumper heartbeat: hook-fires={f}, unique-descriptors={files}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit() => Flush();
|
||||
|
||||
private static void Flush()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (!_dirty) return;
|
||||
_dirty = false;
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int withData = 0;
|
||||
foreach (var kv in Files)
|
||||
{
|
||||
if (kv.Value.Length == 0) continue;
|
||||
withData++;
|
||||
sb.Append(kv.Key).Append('\t').Append(kv.Value).Append('\n');
|
||||
string fp = Path.Combine(_filesDir, Sanitize(kv.Key) + ".b64");
|
||||
if (!File.Exists(fp)) File.WriteAllText(fp, kv.Value);
|
||||
}
|
||||
File.WriteAllText(Path.Combine(_outDir, "manifest.tsv"), sb.ToString());
|
||||
string line = $"captured files={Files.Count} (with-data={withData})";
|
||||
File.AppendAllText(Path.Combine(_outDir, "dump.log"), line + "\n");
|
||||
_log?.Msg($"ProtoDumper: {line} -> {_outDir}");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper flush error: {e}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string protoPath)
|
||||
{
|
||||
var sb = new StringBuilder(protoPath.Length);
|
||||
foreach (char c in protoPath)
|
||||
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.' ? c : '_');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# DorkNet ProtoDumper
|
||||
|
||||
Runtime extractor for Rec Room's Google.Protobuf schema (room save /
|
||||
creation / circuits data model). Produces clean `.proto` files for any
|
||||
client build — including obfuscated ones like 2023.03.21 where static
|
||||
reconstruction from `global-metadata.dat` is unreliable (the descriptor
|
||||
base64 is split into scattered string-literal chunks whose concat order
|
||||
only lives in IL).
|
||||
|
||||
At runtime every `FileDescriptor` still carries its original,
|
||||
un-obfuscated `FileDescriptorProto` (real message names, field names,
|
||||
numbers and types intact). The catch: Rec Room builds these descriptors
|
||||
**lazily and in a specific dependency order** as content loads. Trying to
|
||||
force-build them by reading a message type's static descriptor out of
|
||||
order throws inside the type's static constructor, and the failure is
|
||||
cached — *poisoning* the type (and crashing the client). So this mod does
|
||||
**not** enumerate types.
|
||||
|
||||
Instead it Harmony-postfixes the one library method every generated cctor
|
||||
funnels through —
|
||||
`Google.Protobuf.Reflection.FileDescriptor.BuildFrom` /
|
||||
`.FromGeneratedCode` — and records each descriptor as the game builds it.
|
||||
Completely passive: **play the client through the content you care about**
|
||||
and every descriptor that loads is captured.
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. **Build the mod**
|
||||
|
||||
```sh
|
||||
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||
```
|
||||
|
||||
Output: `DorkNet.ProtoDumper/bin/Release/DorkNet.ProtoDumper.dll`.
|
||||
|
||||
2. **Run it on a *runnable* 2023.03.21 client (MelonLoader 0.6.6)**
|
||||
|
||||
> The `GameAssembly.dll` + `il2cpp_data`-only copy under
|
||||
> `Recnet-old/dist/RecRoom-2023.03.21-il2cpp/…/staging/` is an
|
||||
> **offline-analysis payload, not a runnable build**. Use a full build
|
||||
> with the exe + UnityPlayer + MelonLoader (e.g. the devdork package).
|
||||
|
||||
- Drop `DorkNet.ProtoDumper.dll` into `<RecRoom>\Mods\`. On launch it
|
||||
installs the descriptor-build hooks (see `dump.log` / MelonLoader
|
||||
console for "installed N descriptor-build hooks").
|
||||
- **Log in and play through the content whose schema you want:** your
|
||||
dorm, a created room, the Maker Pen, and a circuits (CV2) board. Each
|
||||
subsystem builds its protos the first time it loads. Descriptors are
|
||||
captured the instant they build — no scene/timer scanning, nothing is
|
||||
force-built, so the client stays stable.
|
||||
- Output appears under `<RecRoom>\DorkNet-ProtoDump\` and is flushed to
|
||||
disk ~1 s after new descriptors load, and again on quit:
|
||||
- `manifest.tsv` — `<proto path>\t<base64>` per file
|
||||
- `files\*.b64` — same, one file each
|
||||
- `dump.log` — running capture count
|
||||
|
||||
3. **Decode to .proto**
|
||||
|
||||
```sh
|
||||
pip install protobuf
|
||||
python DorkNet.ProtoDumper/decode_dump.py <path>/manifest.tsv proto_out
|
||||
```
|
||||
|
||||
Emits one `.proto` per descriptor under `proto_out/`, plus a combined
|
||||
`proto_out/recroom_2023.proto` (every rec_room/circuits message in one
|
||||
file) for an easy diff against
|
||||
`DorkNet.Server/Protos/recroom_2020.proto`.
|
||||
|
||||
## Notes
|
||||
|
||||
- No game DLLs are referenced at compile time. `FileDescriptor` is the
|
||||
Google.Protobuf *library* type (not obfuscated); it's reached by its
|
||||
original name with the Il2Cpp namespace-prefix fallback, and its members
|
||||
are read via reflection. The same DLL works across builds and stays clean
|
||||
of derivative game code.
|
||||
- The only Harmony patches are the two read-only descriptor-build
|
||||
postfixes above — safe to run alongside (or without) `DorkNet.ClientMod`.
|
||||
- The descriptor base64 is the same `FileDescriptorProto` wire format
|
||||
Google's own tooling emits, so `decode_dump.py` (or `protoc
|
||||
--decode=google.protobuf.FileDescriptorProto descriptor.proto`) reads it
|
||||
directly.
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode a DorkNet ProtoDumper manifest into .proto text.
|
||||
|
||||
Usage:
|
||||
python decode_dump.py manifest.tsv [out_dir]
|
||||
|
||||
manifest.tsv has one "<proto path>\\t<base64 FileDescriptorProto>" line per
|
||||
file (as written by the ProtoDumper MelonLoader mod). This emits one .proto
|
||||
file per descriptor under out_dir/ (default: ./proto_out), plus a combined
|
||||
recroom_2023.proto with every rec_room/circuits message in one file for an
|
||||
easy diff against DorkNet.Server/Protos/recroom_2020.proto.
|
||||
|
||||
Requires: pip install protobuf
|
||||
"""
|
||||
import sys, os, base64, re
|
||||
from google.protobuf import descriptor_pb2 as D
|
||||
|
||||
FDP = D.FileDescriptorProto
|
||||
|
||||
# proto3 scalar type names keyed by FieldDescriptorProto.Type
|
||||
SCALAR = {
|
||||
1: "double", 2: "float", 3: "int64", 4: "uint64", 5: "int32",
|
||||
6: "fixed64", 7: "fixed32", 8: "bool", 9: "string", 12: "bytes",
|
||||
13: "uint32", 15: "sfixed32", 16: "sfixed64", 17: "sint32", 18: "sint64",
|
||||
}
|
||||
LABEL_REPEATED = 3
|
||||
|
||||
|
||||
def type_name(f):
|
||||
if f.type in (11, 14): # message / enum -> use type_name (strip leading dot)
|
||||
return f.type_name.lstrip(".")
|
||||
return SCALAR.get(f.type, f"type{f.type}")
|
||||
|
||||
|
||||
def emit_enum(e, indent):
|
||||
pad = " " * indent
|
||||
out = [f"{pad}enum {e.name} {{"]
|
||||
for v in e.value:
|
||||
out.append(f"{pad} {v.name} = {v.number};")
|
||||
out.append(f"{pad}}}")
|
||||
return out
|
||||
|
||||
|
||||
def emit_message(m, indent=0):
|
||||
pad = " " * indent
|
||||
out = [f"{pad}message {m.name} {{"]
|
||||
|
||||
# map<> detection: synthetic nested *Entry types with map_entry option
|
||||
map_entries = {}
|
||||
for nt in m.nested_type:
|
||||
if nt.options.map_entry:
|
||||
kt = type_name(nt.field[0])
|
||||
vt = type_name(nt.field[1])
|
||||
map_entries[nt.name] = (kt, vt)
|
||||
|
||||
# which oneofs are real (not proto3 optional synthetic)
|
||||
for nt in m.nested_type:
|
||||
if nt.name in map_entries:
|
||||
continue
|
||||
out += emit_message(nt, indent + 1)
|
||||
for en in m.enum_type:
|
||||
out += emit_enum(en, indent + 1)
|
||||
|
||||
# group fields by oneof
|
||||
printed_oneof = set()
|
||||
for f in m.field:
|
||||
# map field?
|
||||
if f.type == 11 and f.type_name.split(".")[-1] in map_entries:
|
||||
kt, vt = map_entries[f.type_name.split(".")[-1]]
|
||||
out.append(f"{pad} map<{kt}, {vt}> {f.name} = {f.number};")
|
||||
continue
|
||||
|
||||
if f.HasField("oneof_index") and not f.proto3_optional:
|
||||
idx = f.oneof_index
|
||||
if idx not in printed_oneof:
|
||||
printed_oneof.add(idx)
|
||||
out.append(f"{pad} oneof {m.oneof_decl[idx].name} {{")
|
||||
for g in m.field:
|
||||
if g.HasField("oneof_index") and g.oneof_index == idx and not g.proto3_optional:
|
||||
out.append(f"{pad} {type_name(g)} {g.name} = {g.number};")
|
||||
out.append(f"{pad} }}")
|
||||
continue
|
||||
|
||||
label = "repeated " if f.label == LABEL_REPEATED else ""
|
||||
opt = "optional " if f.proto3_optional else ""
|
||||
out.append(f"{pad} {label}{opt}{type_name(f)} {f.name} = {f.number};")
|
||||
|
||||
out.append(f"{pad}}}")
|
||||
return out
|
||||
|
||||
|
||||
def render_file(fdp):
|
||||
out = []
|
||||
syntax = fdp.syntax or "proto2"
|
||||
out.append(f'syntax = "{syntax}";')
|
||||
out.append("")
|
||||
if fdp.package:
|
||||
out.append(f"package {fdp.package};")
|
||||
for dep in fdp.dependency:
|
||||
out.append(f'import "{dep}";')
|
||||
if fdp.package or fdp.dependency:
|
||||
out.append("")
|
||||
for en in fdp.enum_type:
|
||||
out += emit_enum(en, 0)
|
||||
out.append("")
|
||||
for m in fdp.message_type:
|
||||
out += emit_message(m, 0)
|
||||
out.append("")
|
||||
return "\n".join(out).rstrip() + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__); sys.exit(1)
|
||||
manifest = sys.argv[1]
|
||||
out_dir = sys.argv[2] if len(sys.argv) > 2 else "proto_out"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
files = []
|
||||
with open(manifest, "r", encoding="utf-8") as fh:
|
||||
for ln in fh:
|
||||
ln = ln.rstrip("\n")
|
||||
if not ln or "\t" not in ln:
|
||||
continue
|
||||
name, b64 = ln.split("\t", 1)
|
||||
try:
|
||||
raw = base64.b64decode(b64)
|
||||
fdp = FDP(); fdp.ParseFromString(raw)
|
||||
except Exception as e:
|
||||
print(f" ! failed to parse {name}: {e}")
|
||||
continue
|
||||
files.append((name, fdp))
|
||||
|
||||
print(f"parsed {len(files)} descriptors")
|
||||
|
||||
# per-file .proto
|
||||
for name, fdp in files:
|
||||
safe = re.sub(r"[^A-Za-z0-9_.\-]", "_", name)
|
||||
with open(os.path.join(out_dir, safe), "w", encoding="utf-8") as w:
|
||||
w.write(render_file(fdp))
|
||||
|
||||
# combined rec_room/circuits view (skip google/* well-known imports)
|
||||
rr = [ (n,f) for n,f in files if not n.startswith("google/") ]
|
||||
seen = set(); combo = ['syntax = "proto3";', "", "package recroom;", ""]
|
||||
total_msgs = 0
|
||||
for name, fdp in sorted(rr, key=lambda x: x[0]):
|
||||
combo.append(f"// ===== {name} (package {fdp.package or '-'}) =====")
|
||||
for m in fdp.message_type:
|
||||
if m.name in seen:
|
||||
continue
|
||||
seen.add(m.name)
|
||||
combo += emit_message(m, 0)
|
||||
combo.append("")
|
||||
total_msgs += 1
|
||||
for en in fdp.enum_type:
|
||||
combo += emit_enum(en, 0)
|
||||
combo.append("")
|
||||
with open(os.path.join(out_dir, "recroom_2023.proto"), "w", encoding="utf-8") as w:
|
||||
w.write("\n".join(combo).rstrip() + "\n")
|
||||
print(f"wrote {out_dir}/recroom_2023.proto with {total_msgs} unique messages "
|
||||
f"from {len(rr)} rec_room/circuits descriptors")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression: admin "pull a player into an instance" broke because the
|
||||
/// instance id (an int64 well past JS's 2^53 safe-integer range) was
|
||||
/// emitted as a JSON number. The admin SPA's JSON.parse rounded it to the
|
||||
/// nearest double, so the pull URL targeted a non-existent instance and
|
||||
/// the server returned <c>instance_not_active</c>. The fix serializes the
|
||||
/// id as a STRING so the exact digits survive the browser round-trip.
|
||||
/// </summary>
|
||||
public sealed class AdminInstancePullTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
// A dorm-style instance id past 2^53 — the value class that a JS
|
||||
// Number cannot represent exactly.
|
||||
private const long BigInstanceId = 7597291079085522944L;
|
||||
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public AdminInstancePullTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Instances_list_emits_id_as_string_and_pull_with_exact_id_succeeds()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long roomId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
// Promote the caller to admin (AdminOnly reads IsAdmin from DB).
|
||||
var adminRow = await db.Players.FirstAsync(p => p.Id == admin.PlayerId);
|
||||
adminRow.IsAdmin = true;
|
||||
|
||||
roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Put the streamer in a live instance carrying the huge id.
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = BigInstanceId,
|
||||
RoomId = roomId,
|
||||
SubRoomId = 0,
|
||||
PhotonRoomId = "^teststreamerinstance",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(streamer.PlayerId);
|
||||
}
|
||||
|
||||
using var adminClient = ApiClient(admin);
|
||||
adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
|
||||
|
||||
// The list must emit roomInstanceId as a STRING equal to the exact
|
||||
// digits — never a number (which would already be rounded here).
|
||||
var list = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/instances");
|
||||
var row = list.EnumerateArray()
|
||||
.First(r => r.GetProperty("roomInstanceId").GetString() == BigInstanceId.ToString());
|
||||
Assert.Equal(JsonValueKind.String, row.GetProperty("roomInstanceId").ValueKind);
|
||||
|
||||
// Pull the second player in using the EXACT id from the list. This
|
||||
// is what the SPA does; with the string round-trip the digits are
|
||||
// intact and the instance matches (pre-fix: 404 instance_not_active).
|
||||
var idFromList = row.GetProperty("roomInstanceId").GetString()!;
|
||||
using var pull = await adminClient.PostAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/instances/{idFromList}/pull",
|
||||
new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
|
||||
Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, pull.StatusCode);
|
||||
using var body = await JsonDocument.ParseAsync(await pull.Content.ReadAsStreamAsync());
|
||||
Assert.True(body.RootElement.GetProperty("ok").GetBoolean());
|
||||
// The pulled player now shares the streamer's instance.
|
||||
await using var verify = _factory.Services.CreateAsyncScope();
|
||||
var presenceCheck = verify.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
Assert.Equal(BigInstanceId, presenceCheck.GetRoom(pulled.PlayerId)!.RoomInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_with_precision_mangled_id_is_rejected()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long roomId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
|
||||
await db.SaveChangesAsync();
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = BigInstanceId,
|
||||
RoomId = roomId,
|
||||
PhotonRoomId = "^teststreamerinstance2",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(streamer.PlayerId);
|
||||
}
|
||||
|
||||
using var adminClient = ApiClient(admin);
|
||||
adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
|
||||
|
||||
// The value the admin SPA's JSON.parse actually produced for this
|
||||
// id (from the bug report log) — the exact failure mode.
|
||||
const long mangled = 7597291079085523000L;
|
||||
Assert.NotEqual(BigInstanceId, mangled);
|
||||
using var pull = await adminClient.PostAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/instances/{mangled}/pull",
|
||||
new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
|
||||
Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.NotFound, pull.StatusCode);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class CanonicalRoomBootstrapTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public CanonicalRoomBootstrapTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_seeds_stunt_runner_subroom_and_rec_rally_remote_image_name()
|
||||
{
|
||||
using var _ = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
var stuntRunner = await db.Rooms.SingleAsync(r => r.Name == "StuntRunner");
|
||||
var stuntRunnerScenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == stuntRunner.Id)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Collection(
|
||||
stuntRunnerScenes,
|
||||
scene =>
|
||||
{
|
||||
Assert.Equal(0, scene.OrderIndex);
|
||||
Assert.Equal("StuntRunner", scene.Name);
|
||||
Assert.Equal("b7281665-a715-4051-826b-8e08e69c6172", scene.RoomSceneLocationId);
|
||||
Assert.Equal("", scene.DataBlobName);
|
||||
},
|
||||
scene =>
|
||||
{
|
||||
Assert.Equal(1, scene.OrderIndex);
|
||||
Assert.Equal("TheMainEvent", scene.Name);
|
||||
Assert.Equal("3a636bd2-f896-424c-9225-c184522c0d87", scene.RoomSceneLocationId);
|
||||
Assert.Equal("", scene.DataBlobName);
|
||||
});
|
||||
|
||||
var recRally = await db.Rooms.SingleAsync(r => r.Name == "RecRally");
|
||||
Assert.Equal("image_RecRally.jpg", recRally.ImageName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the 3D Charades word-list feature and the
|
||||
/// profanity-filter toggle. Boots the full server (which runs the seeder)
|
||||
/// against SQLite via <see cref="DorkNetServerFactory"/> and exercises the
|
||||
/// public wire endpoints the March 2023 client actually calls.
|
||||
/// </summary>
|
||||
public sealed class CharadesWordListTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public CharadesWordListTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
private HttpClient ApiClient()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Charades")] // enum-name form
|
||||
[InlineData("CharadesAprilFoolsDay")]
|
||||
[InlineData("Icebreakers")]
|
||||
[InlineData("0")] // int form the client may send
|
||||
[InlineData("1")]
|
||||
[InlineData("2")]
|
||||
public async Task Words_endpoint_returns_seeded_deck_in_wire_shape(string source)
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.GetAsync($"/api/activities/charades/v1/words/{source}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0, $"expected a non-empty deck for '{source}'");
|
||||
|
||||
var first = doc.RootElement[0];
|
||||
// Exact wire key casing the client's LitJson importer reads.
|
||||
Assert.True(first.TryGetProperty("EN_US", out var en) && en.ValueKind == JsonValueKind.String,
|
||||
"each card must expose a string EN_US field");
|
||||
Assert.True(first.TryGetProperty("Difficulty", out var diff) && diff.ValueKind == JsonValueKind.Number,
|
||||
"each card must expose a numeric Difficulty field");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task December_noparam_route_serves_the_charades_slot()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.GetAsync("/api/activities/charades/v1/words");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_source_falls_back_to_charades_slot()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.GetAsync("/api/activities/charades/v1/words/totally-bogus");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Profanity_filter_is_active_by_default()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.PostAsync(
|
||||
"/api/sanitize/v1/requestIsStringPure",
|
||||
new StringContent("{\"Value\":\"you piece of shit\"}", Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
var body = (await res.Content.ReadAsStringAsync()).Trim();
|
||||
// Default state: filter on → the profane string is NOT pure.
|
||||
Assert.Equal("false", body.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Starting a chat with a player is a two-step handshake, and both steps have to
|
||||
/// work on a thread that has never carried a message:
|
||||
///
|
||||
/// POST thread/withmembers?ids={playerId} -> creates/returns the thread
|
||||
/// GET thread/{id} -> opens it
|
||||
///
|
||||
/// Observed failing in production: the POST returned 200 at 06:38:20.862 and the
|
||||
/// GET 404'd 415ms later, because the handler decided a thread existed by
|
||||
/// counting its messages. A brand-new conversation has none, so the client was
|
||||
/// handed a thread id it was then told did not exist, and no chat could ever be
|
||||
/// opened with someone you had not already messaged.
|
||||
/// </summary>
|
||||
public sealed class ChatStartConversationTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ChatStartConversationTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Brand_new_conversation_can_be_opened_before_any_message_is_sent()
|
||||
{
|
||||
using var starterClient = Client();
|
||||
using var otherClient = Client();
|
||||
|
||||
var starter = await GameClientSessionFactory.CreateAsync(starterClient, _factory.ApexDomain);
|
||||
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
|
||||
|
||||
starterClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
|
||||
|
||||
// Step 1 — the client asks for the thread with that player. It sends the
|
||||
// ids as a form body (observed on the wire as
|
||||
// "ids=9741717&messageCount=50"), which also matters for routing: the
|
||||
// content type is what separates this from POST thread/{chatThreadId},
|
||||
// whose parameter segment otherwise also matches "withmembers".
|
||||
using var form = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
|
||||
new KeyValuePair<string, string>("messageCount", "50"),
|
||||
]);
|
||||
using var createResponse = await starterClient.PostAsync("/thread/withmembers", form);
|
||||
var createBody = await createResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(createResponse.IsSuccessStatusCode,
|
||||
$"POST /thread/withmembers -> {(int)createResponse.StatusCode}: {createBody}");
|
||||
|
||||
var thread = JsonDocument.Parse(createBody).RootElement;
|
||||
Assert.True(thread.TryGetProperty("ChatThreadId", out var idElement)
|
||||
|| thread.TryGetProperty("chatThreadId", out idElement),
|
||||
$"no thread id in the withmembers reply: {createBody}");
|
||||
var threadId = idElement.ValueKind == JsonValueKind.String
|
||||
? idElement.GetString()
|
||||
: idElement.ToString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(threadId));
|
||||
|
||||
// Step 2 — and immediately opens it. No message has been sent yet.
|
||||
using var openResponse = await starterClient.GetAsync($"/thread/{threadId}?maxCount=50");
|
||||
var openBody = await openResponse.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.True(openResponse.StatusCode != HttpStatusCode.NotFound,
|
||||
$"""
|
||||
GET /thread/{threadId} returned 404 for a thread that thread/withmembers
|
||||
had just created. An empty thread is not a missing thread — the chat
|
||||
cannot be opened until someone has already sent a message, which is
|
||||
impossible to do first.
|
||||
""");
|
||||
Console.WriteLine("=== WITHMEMBERS ===");
|
||||
Console.WriteLine(createBody);
|
||||
Console.WriteLine("=== GET THREAD ===");
|
||||
Console.WriteLine(openBody);
|
||||
|
||||
Assert.True(openResponse.IsSuccessStatusCode,
|
||||
$"GET /thread/{threadId} -> {(int)openResponse.StatusCode}: {openBody}");
|
||||
}
|
||||
|
||||
private HttpClient Client()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://chat.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts our responses carry EXACTLY the keys the 2023-03-21 client's
|
||||
/// generated readers register — no missing ones, no invented ones.
|
||||
///
|
||||
/// This is stricter than "the client doesn't crash" on purpose. A wrong key set
|
||||
/// does not produce a bad request: the server answers HTTP 200, the reader
|
||||
/// throws while deserialising, and it surfaces later as a bare "Failed to copy
|
||||
/// room" with no URL and no payload logged on either side. By the time it is
|
||||
/// visible there is nothing left to diagnose it with.
|
||||
///
|
||||
/// The expected sets are extracted straight from the binary by
|
||||
/// <c>tools/extract-client-dto-keys.py</c> into
|
||||
/// <c>data/client-dto-keys-2023.json</c>, keyed by the obfuscated reader type.
|
||||
/// Cpp2IL emits each reader's property names as string literals in registration
|
||||
/// order, so that file is the client's own contract rather than our reading of
|
||||
/// it — regenerate it rather than editing it by hand.
|
||||
/// </summary>
|
||||
public sealed class ClientDtoParityTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ClientDtoParityTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
/// <summary>GLEGPFFPDBE — the room-details reader. This is what
|
||||
/// <c>GET rooms/{id}</c> and <c>POST rooms/{id}/clone</c> both deserialise
|
||||
/// into (clone's return type FGCPNAACHIK, NLDBPDCNNCF.txt:4676).</summary>
|
||||
private const string RoomDetailsReader = "GLEGPFFPDBE";
|
||||
|
||||
/// <summary>EKEFFNBIOHJ — the chat-thread reader, shared by
|
||||
/// <c>POST thread/withmembers</c> and <c>GET thread/{id}</c>.</summary>
|
||||
private const string ChatThreadReader = "EKEFFNBIOHJ";
|
||||
|
||||
/// <summary>Keys the reader registers that we deliberately do NOT send.
|
||||
///
|
||||
/// A registered slot does not oblige us to fill it — Newtonsoft leaves an
|
||||
/// absent key at its default. These two were added for parity and broke
|
||||
/// the dorm: which blob a room loads is decided per INSTANCE (the matchmake
|
||||
/// response carries it, and a dorm's is per-player, dorm_p{id}_v*.dat) and
|
||||
/// per SUB-ROOM. A room-scoped answer to the same question is one the
|
||||
/// client preferred, and for a dorm it named a file that was never written,
|
||||
/// so the room came back "corrupt".
|
||||
///
|
||||
/// Every room loaded fine while these were absent. Their absence is the
|
||||
/// fix, not a gap to close later — do not "restore parity" here without a
|
||||
/// dorm join to prove it.</summary>
|
||||
private static readonly HashSet<(string Reader, string Key)> DeliberatelyOmitted =
|
||||
[
|
||||
(RoomDetailsReader, "DataBlob"),
|
||||
(RoomDetailsReader, "DataBlobHash"),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task Room_details_carries_exactly_the_keys_the_client_reads()
|
||||
{
|
||||
using var client = RoomsClient();
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Parity{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = session.PlayerId,
|
||||
CloningAllowed = true,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, Name = "Home", OrderIndex = 0 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var response = await client.GetAsync($"/rooms/{roomId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET /rooms/{roomId} -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
AssertKeyParity(RoomDetailsReader, body, $"GET rooms/{roomId}");
|
||||
|
||||
// The reader walks into the nested objects too, and a wrong key set
|
||||
// there fails exactly the same way — a 200 the client throws on.
|
||||
var details = JsonDocument.Parse(body).RootElement;
|
||||
foreach (var (property, reader) in NestedReaders)
|
||||
{
|
||||
if (!details.TryGetProperty(property, out var value)) continue;
|
||||
|
||||
var element = value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array when value.GetArrayLength() > 0 => value[0],
|
||||
JsonValueKind.Object => value,
|
||||
_ => default,
|
||||
};
|
||||
if (element.ValueKind != JsonValueKind.Object) continue;
|
||||
|
||||
AssertKeyParity(reader, element.GetRawText(),
|
||||
$"GET rooms/{roomId} → {property}{(value.ValueKind == JsonValueKind.Array ? "[0]" : "")}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nested objects in the room-details payload and the reader each
|
||||
/// one is deserialised by. Sub-room 0, the first role, the stats object and
|
||||
/// the first tag all have their own generated readers, and a key set that
|
||||
/// disagrees with them breaks the whole room just as hard as a wrong
|
||||
/// top-level one.</summary>
|
||||
private static readonly (string Property, string Reader)[] NestedReaders =
|
||||
[
|
||||
("SubRooms", "JDEKAFDPGBE"),
|
||||
("Roles", "LMEECKCCPHG"),
|
||||
("Stats", "MLHBPDKBABN"),
|
||||
("Tags", "GEGADPCDGBO"),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task Chat_thread_carries_exactly_the_keys_the_client_reads()
|
||||
{
|
||||
using var client = ChatClient();
|
||||
using var otherClient = ChatClient();
|
||||
var starter = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
|
||||
|
||||
using var form = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
|
||||
new KeyValuePair<string, string>("messageCount", "50"),
|
||||
]);
|
||||
using var response = await client.PostAsync("/thread/withmembers", form);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"POST /thread/withmembers -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
AssertKeyParity(ChatThreadReader, body, "POST thread/withmembers");
|
||||
}
|
||||
|
||||
/// <summary>Compare one response object's keys against the reader's set.
|
||||
/// Matching is case-insensitive because the readers register three spellings
|
||||
/// per field (Pascal, camel, lower) and accept any of them — casing is the
|
||||
/// one thing they are NOT strict about.</summary>
|
||||
private static void AssertKeyParity(string readerType, string body, string what)
|
||||
{
|
||||
var expected = LoadReaderKeys(readerType)
|
||||
.Where(k => !DeliberatelyOmitted.Contains((readerType, k)))
|
||||
.ToList();
|
||||
var actual = JsonDocument.Parse(body).RootElement
|
||||
.EnumerateObject().Select(p => p.Name)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var missing = expected.Where(k => !actual.Contains(k)).ToList();
|
||||
var extra = actual
|
||||
.Where(k => !expected.Contains(k, StringComparer.OrdinalIgnoreCase))
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
Assert.True(missing.Count == 0 && extra.Count == 0,
|
||||
$"""
|
||||
{what} does not match what the client's {readerType} reader expects.
|
||||
|
||||
MISSING ({missing.Count}) — the reader registers these and we never send them;
|
||||
a non-nullable field left null is what throws mid-deserialise:
|
||||
{string.Join(", ", missing)}
|
||||
|
||||
UNEXPECTED ({extra.Count}) — we send these and the reader has no slot for them:
|
||||
{string.Join(", ", extra)}
|
||||
|
||||
Expected, in the reader's own registration order:
|
||||
{string.Join(", ", expected)}
|
||||
""");
|
||||
}
|
||||
|
||||
private static List<string> LoadReaderKeys(string readerType)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-dto-keys-2023.json");
|
||||
Assert.True(File.Exists(path),
|
||||
$"missing {path} — regenerate with tools/extract-client-dto-keys.py");
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
Assert.True(doc.RootElement.TryGetProperty(readerType, out var keys),
|
||||
$"{readerType} is not in the extracted catalogue");
|
||||
return keys.EnumerateArray().Select(k => k.GetString()!).ToList();
|
||||
}
|
||||
|
||||
private HttpClient RoomsClient() => ClientFor("rooms");
|
||||
|
||||
private HttpClient ChatClient() => ClientFor("chat");
|
||||
|
||||
private HttpClient ClientFor(string host)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that every HTTP request the Rec Room 2023-03-21 client can issue has
|
||||
/// a handler on this server, under the verb the client actually uses.
|
||||
///
|
||||
/// Rec Room is shut down, so the client can't be observed against a live server.
|
||||
/// <c>data/client-routes-2023.tsv</c> is the recovered inventory of what it
|
||||
/// asks for — see <c>docs/recroom-2023-client-api-complete.md</c> for how it was
|
||||
/// extracted. This test is the regression guard on that inventory: adding a
|
||||
/// client route without a handler, or moving a handler to a different verb,
|
||||
/// fails here rather than silently 404/405-ing a real player.
|
||||
///
|
||||
/// Routes not yet implemented live in <see cref="KnownGaps"/>. That list is
|
||||
/// expected to shrink and never grow. Deleting an entry as you implement it is
|
||||
/// the point — it is the project's running score against the client.
|
||||
/// </summary>
|
||||
public sealed class ClientRouteCoverageTests
|
||||
{
|
||||
/// <summary>Client routes with no handler yet. Each entry is
|
||||
/// <c>VERB route</c> exactly as it appears in the inventory file.
|
||||
///
|
||||
/// This is EMPTY, and that is the point: every request the 2023-03-21
|
||||
/// client can issue reaches a handler under the verb it actually uses. Do
|
||||
/// not add an entry to make a red build green — an entry here is a promise
|
||||
/// that the client gets a 404 or 405 in the wild. Implement the route, or
|
||||
/// if the inventory itself is wrong (a literal that is really a cache key,
|
||||
/// a deeplink, or a base the client concatenates onto), fix the inventory
|
||||
/// so the claim stays honest.</summary>
|
||||
private static readonly HashSet<string> KnownGaps = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[Fact]
|
||||
public void Every_client_route_has_a_handler()
|
||||
{
|
||||
var client = LoadClientRoutes();
|
||||
Assert.NotEmpty(client);
|
||||
|
||||
var discovered = EndpointContractDiscovery.Discover()
|
||||
.Select(r => (Verb: r.Method.ToUpperInvariant(), Pattern: Normalise(r.Template)))
|
||||
.ToList();
|
||||
|
||||
var server = discovered.ToHashSet();
|
||||
|
||||
// A bare [Route] with no verb attribute serves every verb.
|
||||
var verbless = discovered
|
||||
.Where(d => d.Verb == "ANY")
|
||||
.Select(d => d.Pattern)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var serverAnyVerb = discovered.Select(s => s.Pattern).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = new List<string>();
|
||||
var wrongVerb = new List<string>();
|
||||
|
||||
foreach (var (verb, route, _) in client)
|
||||
{
|
||||
var pattern = Normalise(route);
|
||||
if (server.Contains((verb, pattern)) || verbless.Contains(pattern)) continue;
|
||||
|
||||
// Distinguish "no handler at all" from "handler exists, wrong verb" —
|
||||
// they have different causes and different fixes.
|
||||
if (serverAnyVerb.Contains(pattern)) wrongVerb.Add($"{verb} {route}");
|
||||
else missing.Add($"{verb} {route}");
|
||||
}
|
||||
|
||||
var gaps = missing.Concat(wrongVerb)
|
||||
.Where(g => !KnownGaps.Contains(g))
|
||||
.OrderBy(g => g, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var covered = client.Count - missing.Count - wrongVerb.Count;
|
||||
var pct = 100.0 * covered / client.Count;
|
||||
|
||||
Assert.True(gaps.Count == 0,
|
||||
$"""
|
||||
Client route coverage: {covered}/{client.Count} ({pct:F1}%).
|
||||
{missing.Count} missing, {wrongVerb.Count} wrong-verb, {KnownGaps.Count} known gaps.
|
||||
|
||||
Unlisted gaps ({gaps.Count}) — implement them, or add to KnownGaps with a reason:
|
||||
{string.Join(Environment.NewLine, gaps)}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Collapse a route to a comparable shape: no host, no leading or
|
||||
/// trailing slash, no query, and every parameter — the client's <c>{0}</c>
|
||||
/// and the server's <c>{id:long}</c> alike — reduced to <c>*</c>.</summary>
|
||||
private static string Normalise(string route)
|
||||
{
|
||||
var r = route.Trim();
|
||||
var q = r.IndexOf('?');
|
||||
if (q >= 0) r = r[..q];
|
||||
r = r.Trim('/', '~');
|
||||
r = Regex.Replace(r, @"\{[^}]*\}", "*");
|
||||
return r.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static List<(string Verb, string Route, string Subsystem)> LoadClientRoutes()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-routes-2023.tsv");
|
||||
Assert.True(File.Exists(path), $"missing route inventory at {path}");
|
||||
|
||||
var rows = new List<(string, string, string)>();
|
||||
foreach (var line in File.ReadLines(path))
|
||||
{
|
||||
if (line.Length == 0 || line[0] == '#') continue;
|
||||
var parts = line.Split('\t');
|
||||
if (parts.Length < 2) continue;
|
||||
rows.Add((parts[0].Trim().ToUpperInvariant(), parts[1].Trim(),
|
||||
parts.Length > 2 ? parts[2].Trim() : string.Empty));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<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" />
|
||||
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Recovered client route inventory read by ClientRouteCoverageTests. -->
|
||||
<Content Include="data\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,90 @@
|
||||
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"] = "march_2023_03_21",
|
||||
["DorkNet:SupportedVersions:0"] = "march_2023_03_21",
|
||||
});
|
||||
});
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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>()
|
||||
.Cast<IRouteTemplateProvider>()
|
||||
.ToArray();
|
||||
|
||||
// An action can carry a bare [Route("...")] with no [HttpGet]/[HttpPost],
|
||||
// which routes ALL verbs — the CDN's unity_assets handler is the live
|
||||
// example. Skipping those made the discovered table under-report the
|
||||
// server, so route-coverage checks saw phantom gaps.
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<IRouteTemplateProvider>()
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a.Template))
|
||||
.ToArray();
|
||||
}
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attr in httpAttrs)
|
||||
{
|
||||
var attrMethods = attr is HttpMethodAttribute { HttpMethods: var verbs } && verbs.Any()
|
||||
? verbs
|
||||
: 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
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()
|
||||
.Where(r => !DestroysTheProbeSession(r))
|
||||
.ToList();
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Routes this probe must not call, because they destroy the very
|
||||
/// session it authenticates with.
|
||||
///
|
||||
/// The probe walks every route in order using one test player. Account
|
||||
/// deletion is a genuine, correctly-guarded purge — but once it runs, that
|
||||
/// player row is gone, so every later probe fails on a foreign-key
|
||||
/// violation or a 401. Excluding these keeps the failure list meaningful
|
||||
/// instead of one real event cascading into dozens of phantom failures.
|
||||
///
|
||||
/// This is deliberately narrow: only self-destruct routes belong here, not
|
||||
/// anything merely inconvenient.</summary>
|
||||
private static bool DestroysTheProbeSession(EndpointContract route)
|
||||
{
|
||||
if (!string.Equals(route.Method, "DELETE", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var template = route.Template.Trim('/');
|
||||
return template is "account/me" or "account/v1/me" or "account/v2/me";
|
||||
}
|
||||
|
||||
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/2023.03.21");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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/2023.03.21");
|
||||
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
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/2023.03.21");
|
||||
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Net;
|
||||
using DorkNet.Contracts;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for the 2023 "pull a player into a private instance"
|
||||
/// flow. The 2023 client renamed the matchmaking surface from <c>goto/*</c>
|
||||
/// to <c>matchmake/*</c> and added <c>matchmake/chatinvite/{account}/{instance}</c>
|
||||
/// for chat party invites; the server had neither the routes nor the gateway
|
||||
/// ownership entry, so those requests 404'd.
|
||||
/// </summary>
|
||||
public sealed class MatchmakePullInviteTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public MatchmakePullInviteTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
private HttpClient ApiClient()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/matchmake/instance/999")]
|
||||
[InlineData("/matchmake/invite/999")]
|
||||
[InlineData("/matchmake/chatinvite/2/16777216")] // {accountId}/{roomInstanceId}
|
||||
[InlineData("/matchmake/player/2")]
|
||||
[InlineData("/matchmake/event/999")]
|
||||
[InlineData("/matchmake/club/999")]
|
||||
[InlineData("/matchmake/code/5/ABCDEF")] // {roomId}/{code}
|
||||
public async Task Matchmake_pull_routes_exist(string path)
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.PostAsync(path, new StringContent(string.Empty));
|
||||
|
||||
// The bug was a hard 404 (no route). These resolve to a dorm-fallback
|
||||
// matchmaking response when the instance/invite doesn't exist, so any
|
||||
// non-404 status proves the route is now mounted.
|
||||
Assert.NotEqual(HttpStatusCode.NotFound, res.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/matchmake/instance/999")]
|
||||
[InlineData("/matchmake/invite/999")]
|
||||
[InlineData("/matchmake/chatinvite/2/16777216")]
|
||||
[InlineData("/matchmake/room/5")]
|
||||
[InlineData("/matchmake/player/2")]
|
||||
[InlineData("/matchmake/event/999")]
|
||||
[InlineData("/matchmake/club/999")]
|
||||
[InlineData("/matchmake/code/5/ABCDEF")]
|
||||
public void Matchmake_paths_route_to_same_service_as_goto(string path)
|
||||
{
|
||||
const string apex = "dork.test";
|
||||
var gotoOwner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", "/goto/instance/999", apex);
|
||||
var owner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", path, apex);
|
||||
|
||||
// matchmake/* must land on the same service that owns goto/* — before
|
||||
// the fix it fell through to the Monolith default owner.
|
||||
Assert.Equal(gotoOwner, owner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// api/avatar/v4/items — the owned/unlocked wardrobe. The 2023 client's
|
||||
/// owned-item DTO (RecNet IFNGBPJGINL) requires <c>AvatarItemId</c> (Int32),
|
||||
/// <c>TagList</c> (string) and <c>IsBaseAvatarItem</c> (bool). Omitting them
|
||||
/// left every item at AvatarItemId 0; the client keys the wardrobe by that
|
||||
/// id, so the second item collided, the unlocked-items list failed to build
|
||||
/// (empty wardrobe = "no owned items"), and any refetch — including the one
|
||||
/// after a purchase — NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew
|
||||
/// ("can't buy items"). Each item must carry a unique, non-zero id.
|
||||
/// </summary>
|
||||
public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public OwnedAvatarItemsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Owned_items_carry_unique_nonzero_ids_and_2023_keys()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
Assert.True(items.GetArrayLength() > 0, "starter wardrobe was empty");
|
||||
|
||||
var ids = new List<int>();
|
||||
foreach (var it in items.EnumerateArray())
|
||||
{
|
||||
// Every 2023 key present and correctly typed.
|
||||
Assert.Equal(JsonValueKind.Number, it.GetProperty("AvatarItemId").ValueKind);
|
||||
Assert.Equal(JsonValueKind.String, it.GetProperty("TagList").ValueKind);
|
||||
var isBase = it.GetProperty("IsBaseAvatarItem").ValueKind;
|
||||
Assert.True(isBase is JsonValueKind.True or JsonValueKind.False);
|
||||
Assert.Equal(JsonValueKind.String, it.GetProperty("AvatarItemDesc").ValueKind);
|
||||
|
||||
var id = it.GetProperty("AvatarItemId").GetInt32();
|
||||
Assert.NotEqual(0, id);
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// No duplicate ids — a collision is what crashed the wardrobe.
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Purchased_item_appears_owned_with_valid_shape()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var before = (await GetJsonAsync(client, "/api/avatar/v4/items")).GetArrayLength();
|
||||
|
||||
// Buy the first avatar-backed watch-storefront item.
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
|
||||
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
|
||||
.GetProperty("PurchasableItemId").GetInt32();
|
||||
|
||||
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
|
||||
new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
StorefrontType = 3,
|
||||
PurchasableItemId = purchasableId,
|
||||
CurrencyType = 2,
|
||||
RequestedPrice = 1,
|
||||
Gift = (object?)null,
|
||||
}), Encoding.UTF8, "application/json"));
|
||||
buy.EnsureSuccessStatusCode();
|
||||
|
||||
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
Assert.True(after.GetArrayLength() >= before, "owned count shrank after purchase");
|
||||
// Still no duplicate ids after the new item joins the list.
|
||||
var ids = after.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Buy_gift_desc_matches_v4items_and_consume_grants_once()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
// Buy an avatar-backed item — the server queues a post-purchase gift box.
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
|
||||
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
|
||||
.GetProperty("PurchasableItemId").GetInt32();
|
||||
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
|
||||
new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
StorefrontType = 3, PurchasableItemId = purchasableId,
|
||||
CurrencyType = 2, RequestedPrice = 1, Gift = (object?)null,
|
||||
}), Encoding.UTF8, "application/json"));
|
||||
buy.EnsureSuccessStatusCode();
|
||||
|
||||
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
|
||||
Assert.True(gifts.GetArrayLength() > 0, "buy did not queue a gift box");
|
||||
var gift = gifts[0];
|
||||
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
|
||||
|
||||
// The gift's desc MUST be the same 4-part "{guid},,," form v4/items
|
||||
// emits — the client matches on it verbatim in
|
||||
// OutfitManager.UnlockAvatarItemAndMarkNew after the box is consumed;
|
||||
// a bare guid missed and NRE'd ("items removed on every buy").
|
||||
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"gift desc not 4-part: {giftDesc}");
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()).ToHashSet();
|
||||
Assert.Contains(giftDesc, descs);
|
||||
|
||||
// Consuming the box must not add a SECOND wardrobe row for the item
|
||||
// the direct purchase already granted as a bare guid.
|
||||
using var consume = await client.PostAsync(
|
||||
$"/api/avatar/v2/gifts/consume/{gift.GetProperty("Id").GetInt64()}",
|
||||
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
consume.EnsureSuccessStatusCode();
|
||||
|
||||
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var baseGuid = giftDesc.Split(',')[0];
|
||||
var occurrences = after.EnumerateArray()
|
||||
.Count(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0] == baseGuid);
|
||||
Assert.Equal(1, occurrences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Legacy_bare_desc_gift_is_served_as_4part_matching_v4items()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
// Simulate a gift persisted BEFORE the fix: a bare-guid outfit desc
|
||||
// (no ",,," suffix) — the class of row that made the post-consume
|
||||
// UnlockAvatarItemAndMarkNew Find() miss and NRE.
|
||||
string bareGuid;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<Data.DorkNetDbContext>();
|
||||
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
|
||||
bareGuid = item.Slug["wardrobe-".Length..];
|
||||
db.GiftPackages.Add(new Data.Entities.GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = player.PlayerId,
|
||||
AvatarItemType = 0, // outfit
|
||||
AvatarItemDescOrHairDyeDesc = bareGuid, // BARE — the legacy bug
|
||||
IsValid = true,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
|
||||
var gift = gifts.EnumerateArray()
|
||||
.First(g => g.GetProperty("AvatarItemDesc").GetString()!.StartsWith(bareGuid));
|
||||
|
||||
// Both desc keys are served as the 4-part form regardless of how the
|
||||
// row was stored, so the client's exact-match unlock succeeds. (A
|
||||
// real buy also grants the item, so v4/items contains it — covered by
|
||||
// Purchased_item_appears_owned / Buy_gift_desc_matches_v4items.)
|
||||
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
|
||||
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"legacy gift not normalized: {giftDesc}");
|
||||
Assert.Equal(giftDesc, gift.GetProperty("AvatarItemDescOrHairDyeDesc").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Toggle_on_wardrobe_includes_color_variants_with_unique_ids()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var settings = scope.ServiceProvider
|
||||
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>();
|
||||
await settings.SetAllAvatarItemsOwnedEnabledAsync(true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = ApiClient(player);
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
|
||||
// Variant descs ("{guid},{combo},{mask},") — the colorways — are
|
||||
// present, not just base "{guid},,," rows; and every id is unique
|
||||
// (base + variants share a base guid, so a base-guid hash would
|
||||
// collide → wardrobe dict crash).
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||
var hasVariant = descs.Any(d =>
|
||||
{
|
||||
var p = d.Split(',');
|
||||
return p.Length >= 2 && p[1].Length > 0;
|
||||
});
|
||||
Assert.True(hasVariant, "toggle-on wardrobe has no colored variants");
|
||||
|
||||
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// While a join is pending, the heartbeat must tell the joining player which
|
||||
/// instance they are joining — never null.
|
||||
///
|
||||
/// Deferring the presence commit is about what everyone ELSE sees: presence
|
||||
/// stays uncommitted until the join is confirmed so a failed join leaves no
|
||||
/// ghost behind. It must not change what the joining player is told about
|
||||
/// themselves, because the client XORs (cachedRoomInstance != null) against
|
||||
/// (serverRoomInstance != null): holding the target while the server says null
|
||||
/// reads as the room vanishing mid-join. The client then logs "presence
|
||||
/// heartbeat response indicates local presence is out-of-sync", "Attempting to
|
||||
/// go to Invalid RoomInstance", and cancels the very join the server was
|
||||
/// waiting on — so it retries until a heartbeat happens to miss the window, and
|
||||
/// entering a dorm takes several attempts while looking like a load failure.
|
||||
/// </summary>
|
||||
public sealed class PendingJoinPresenceTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PendingJoinPresenceTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Heartbeat_during_a_pending_dorm_join_reports_the_instance_being_joined()
|
||||
{
|
||||
using var client = Client("match");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
// matchmake/dorm is what arms the deferred-commit pending join.
|
||||
using var dormForm = new FormUrlEncodedContent(
|
||||
[new KeyValuePair<string, string>("MaxPersistenceVersion", "45")]);
|
||||
using var dorm = await client.PostAsync("/matchmake/dorm", dormForm);
|
||||
var dormBody = await dorm.Content.ReadAsStringAsync();
|
||||
Assert.True(dorm.IsSuccessStatusCode,
|
||||
$"POST /matchmake/dorm -> {(int)dorm.StatusCode}: {dormBody}");
|
||||
|
||||
var expectedInstance = JsonDocument.Parse(dormBody).RootElement;
|
||||
var instanceId = FindInstanceId(expectedInstance);
|
||||
Assert.True(instanceId is not null, $"no roomInstanceId in the dorm reply: {dormBody}");
|
||||
|
||||
// The join has NOT been reported yet, so the commit is still deferred —
|
||||
// this is exactly the window the client heartbeats in.
|
||||
using var beatForm = new FormUrlEncodedContent(
|
||||
[new KeyValuePair<string, string>("LoginLock", Guid.NewGuid().ToString())]);
|
||||
using var beat = await client.PostAsync("/player/heartbeat", beatForm);
|
||||
var beatBody = await beat.Content.ReadAsStringAsync();
|
||||
Assert.True(beat.IsSuccessStatusCode,
|
||||
$"POST /player/heartbeat -> {(int)beat.StatusCode}: {beatBody}");
|
||||
|
||||
var presence = JsonDocument.Parse(beatBody).RootElement;
|
||||
Assert.True(presence.TryGetProperty("roomInstance", out var reported)
|
||||
|| presence.TryGetProperty("RoomInstance", out reported),
|
||||
$"no roomInstance key in the heartbeat: {beatBody}");
|
||||
|
||||
Assert.True(reported.ValueKind != JsonValueKind.Null,
|
||||
$"""
|
||||
The heartbeat returned a null roomInstance while the player's join was
|
||||
still pending. The client reads that as the room vanishing mid-join and
|
||||
cancels the load it is waiting on:
|
||||
|
||||
{beatBody}
|
||||
""");
|
||||
|
||||
Assert.Equal(instanceId, FindInstanceId(reported));
|
||||
}
|
||||
|
||||
/// <summary>Pull roomInstanceId out of either the matchmake reply (which
|
||||
/// wraps it) or the heartbeat's roomInstance object, whatever the casing.</summary>
|
||||
private static string? FindInstanceId(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value.ToString();
|
||||
|
||||
if (property.Value.ValueKind == JsonValueKind.Object
|
||||
&& FindInstanceId(property.Value) is { } nested)
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private HttpClient Client(string host)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Private-room semantics:
|
||||
/// 1. A freshly cloned room is PRIVATE (Accessibility=0) regardless of
|
||||
/// the source's visibility — the owner publishes explicitly.
|
||||
/// 2. A private room has exactly ONE instance per sub-room: every
|
||||
/// JoinMode (plain matchmake / new public / new private) funnels
|
||||
/// into the same deterministic invite-gated instance instead of
|
||||
/// nonce-forking parallel copies.
|
||||
/// 3. Players who are neither the creator nor role-holders/invitees
|
||||
/// are turned away with ErrorCode=4 (indistinguishable from a
|
||||
/// nonexistent room, so private rooms don't leak).
|
||||
/// </summary>
|
||||
public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PrivateRoomInstanceTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_is_private_by_default()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var sourceId = 9_400_100L;
|
||||
await SeedRoomAsync(sourceId, creatorId: owner.PlayerId, accessibility: 1);
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
using var resp = await roomsClient.PostAsync(
|
||||
$"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"PrivClone_{Guid.NewGuid():N}"[..24] }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64();
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var clone = await db.Rooms.SingleAsync(r => r.Id == cloneId);
|
||||
Assert.Equal(0, clone.Accessibility); // private by default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Private_room_gets_one_instance_per_subroom_for_all_join_modes()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_400_200L;
|
||||
await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0);
|
||||
|
||||
using var matchClient = ApiClient(owner, subdomain: "match");
|
||||
|
||||
var joins = new List<(long InstanceId, string Photon, bool IsPrivate)>();
|
||||
foreach (var joinMode in new[] { 0, 1, 2, 0 })
|
||||
{
|
||||
using var resp = await matchClient.PostAsync(
|
||||
$"/matchmake/room/{roomId}",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["JoinMode"] = joinMode.ToString() }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"JoinMode={joinMode} -> {(int)resp.StatusCode}: {text}");
|
||||
using var json = JsonDocument.Parse(text);
|
||||
Assert.Equal(0, json.RootElement.GetProperty("errorCode").GetInt32());
|
||||
var inst = json.RootElement.GetProperty("roomInstance");
|
||||
joins.Add((
|
||||
inst.GetProperty("roomInstanceId").GetInt64(),
|
||||
inst.GetProperty("photonRoomId").GetString()!,
|
||||
inst.GetProperty("isPrivate").GetBoolean()));
|
||||
}
|
||||
|
||||
// Every join mode must land in the SAME single instance.
|
||||
Assert.Single(joins.Select(j => j.InstanceId).Distinct());
|
||||
Assert.Single(joins.Select(j => j.Photon).Distinct());
|
||||
Assert.All(joins, j => Assert.True(j.IsPrivate, "private room instances must be invite-gated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Private_room_turns_away_uninvited_players()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
var stranger = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_400_300L;
|
||||
await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0);
|
||||
|
||||
using var strangerClient = ApiClient(stranger, subdomain: "match");
|
||||
using var resp = await strangerClient.PostAsync(
|
||||
$"/matchmake/room/{roomId}",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["JoinMode"] = "0" }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
// 4 = RoomDoesNotExist — private rooms are invisible, not "forbidden".
|
||||
Assert.Equal(4, json.RootElement.GetProperty("errorCode").GetInt32());
|
||||
}
|
||||
|
||||
private async Task SeedRoomAsync(long roomId, long creatorId, int accessibility)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (await db.Rooms.AnyAsync(r => r.Id == roomId)) return;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"PrivRoom_{roomId}",
|
||||
Description = "private-room test",
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = accessibility,
|
||||
CloningAllowed = true,
|
||||
LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = 0,
|
||||
Name = "Home",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The end-of-quest reward chest is the gifts/generate + gifts/consume
|
||||
/// flow. This covers the load-bearing new behaviour: a gift stamped with
|
||||
/// a <c>SourceStoreItemId</c> (how quest chests are built) grants exactly
|
||||
/// that store item to the player's inventory when the box is opened,
|
||||
/// regardless of item kind. (The room-keyed pick itself is data-driven
|
||||
/// via data/quest_rewards.json and exercised once real per-quest lists
|
||||
/// are configured.)
|
||||
/// </summary>
|
||||
public sealed class QuestRewardChestTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public QuestRewardChestTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Consuming_quest_chest_grants_backing_item()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long giftId;
|
||||
string wardrobeGuid;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
|
||||
wardrobeGuid = item.Slug["wardrobe-".Length..];
|
||||
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = player.PlayerId,
|
||||
AvatarItemType = 0,
|
||||
AvatarItemDescOrHairDyeDesc = wardrobeGuid + ",,,",
|
||||
Message = "You earned it!",
|
||||
SourceStoreItemId = item.Id,
|
||||
IsValid = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
giftId = gift.Id;
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
using var consume = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
|
||||
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
consume.EnsureSuccessStatusCode();
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == player.PlayerId);
|
||||
Assert.NotNull(avatar);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(avatar!.InventoryJson) ?? new();
|
||||
Assert.Contains(wardrobeGuid, inv);
|
||||
|
||||
var gift = await db.GiftPackages.FirstAsync(g => g.Id == giftId);
|
||||
Assert.True(gift.Consumed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consuming_is_idempotent_and_does_not_double_grant()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long giftId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = player.PlayerId,
|
||||
AvatarItemType = 0,
|
||||
AvatarItemDescOrHairDyeDesc = item.Slug["wardrobe-".Length..] + ",,,",
|
||||
SourceStoreItemId = item.Id,
|
||||
IsValid = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
giftId = gift.Id;
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
using var r = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
|
||||
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
r.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
// The wardrobe guid appears exactly once despite two consumes.
|
||||
Assert.Equal(inv.Distinct().Count(), inv.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using DorkNet.Server.Services;
|
||||
using Google.Protobuf;
|
||||
using RecRoom.Protobuf;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A modern RecNet export's subroom save stamps PersistedRoomData
|
||||
/// versions past what the March-2023 client knows (field 1
|
||||
/// DEPRECATED_RoomPersistenceVersion, max 38; field 30
|
||||
/// PersistedRoomVersion, max 19 = V19February23BetaRelease). The client
|
||||
/// rejects the whole room with its "update Rec Room to visit this room"
|
||||
/// gate — the ShibuyaCrossing import (Sep-2025 save, version=131) is the
|
||||
/// canonical repro. The CDN serve path clamps both varints down; these
|
||||
/// tests pin the rewrite to exactly those two fields.
|
||||
/// </summary>
|
||||
public sealed class RoomBlobVersionClampTests
|
||||
{
|
||||
private static byte[] BuildModernBlob(int deprecatedVersion, int version)
|
||||
{
|
||||
// Emit the wire shape a modern save actually has: version fields
|
||||
// plus payload fields the 2023 schema does and doesn't know about.
|
||||
using var ms = new MemoryStream();
|
||||
var output = new CodedOutputStream(ms);
|
||||
output.WriteTag(1, WireFormat.WireType.Varint);
|
||||
output.WriteInt32(deprecatedVersion);
|
||||
// Field 2: persistence_views payload (opaque here — the clamp
|
||||
// must copy it verbatim).
|
||||
output.WriteTag(2, WireFormat.WireType.LengthDelimited);
|
||||
output.WriteBytes(ByteString.CopyFrom(0x0A, 0x03, 0x01, 0x02, 0x03));
|
||||
// Field 6: sub_room_id.
|
||||
output.WriteTag(6, WireFormat.WireType.Varint);
|
||||
output.WriteInt64(19589779);
|
||||
output.WriteTag(30, WireFormat.WireType.Varint);
|
||||
output.WriteInt32(version);
|
||||
// Field 37: unknown-to-2023 varint field from newer schemas.
|
||||
output.WriteTag(37, WireFormat.WireType.Varint);
|
||||
output.WriteInt32(1);
|
||||
output.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clamps_modern_versions_to_2023_maxima()
|
||||
{
|
||||
var input = BuildModernBlob(deprecatedVersion: 38, version: 131);
|
||||
|
||||
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
Assert.True(changed);
|
||||
var msg = PersistedRoomData.Parser.ParseFrom(bytes);
|
||||
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)msg.Version);
|
||||
Assert.Equal(DEPRECATED_RoomPersistenceVersion.LatestRoomPersistenceVersion, msg.DEPRECATEDVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preserves_every_non_version_byte()
|
||||
{
|
||||
var input = BuildModernBlob(deprecatedVersion: 38, version: 131);
|
||||
|
||||
var (bytes, _) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
// version=131 is a two-byte varint, 16 is one byte — the output
|
||||
// shrinks by exactly that and matches the input everywhere else.
|
||||
Assert.Equal(input.Length - 1, bytes.Length);
|
||||
var expected = BuildModernBlob(
|
||||
deprecatedVersion: 38,
|
||||
version: RoomDataBlobService.Client2023MaxPersistedRoomVersion);
|
||||
Assert.Equal(expected, bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blob_already_at_2023_versions_passes_through_unchanged()
|
||||
{
|
||||
var input = BuildModernBlob(
|
||||
deprecatedVersion: 38,
|
||||
version: RoomDataBlobService.Client2023MaxPersistedRoomVersion);
|
||||
|
||||
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Same(input, bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_input_is_returned_verbatim()
|
||||
{
|
||||
var input = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01 };
|
||||
|
||||
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Same(input, bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 play-menu browse sources. The client's room-discovery URL
|
||||
/// builder (IBEOONPEELF/GGDPFFDEDKN) requests
|
||||
/// <c>rooms/search_rooms/{query}&skip={skip}&take={take}</c> —
|
||||
/// note the paging pairs live INSIDE the final path segment, and the
|
||||
/// whole family is prefixed with <c>rooms/</c> on the rooms host.
|
||||
/// The room photo gallery sends <c>sort</c>/<c>filter</c> as enum
|
||||
/// names (<c>CheerCount_Desc</c>, <c>PublicOnly</c>), which must not
|
||||
/// trip int model binding.
|
||||
/// </summary>
|
||||
public sealed class RoomBrowse2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomBrowse2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Search_rooms_2023_path_with_embedded_paging_returns_matches()
|
||||
{
|
||||
using var setupClient = Client();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var marker = $"Zz{Guid.NewGuid():N}"[..14];
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = 9_300_000 + Random.Shared.Next(1, 99_999),
|
||||
Name = $"Search{marker}",
|
||||
Description = "2023 search test room",
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var roomsClient = Client(owner, subdomain: "rooms");
|
||||
using var response = await roomsClient.GetAsync($"/rooms/search_rooms/{marker}&skip=0&take=20");
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var names = json.RootElement.EnumerateArray()
|
||||
.Select(e => e.GetProperty("Name").GetString() ?? string.Empty)
|
||||
.ToArray();
|
||||
Assert.Contains(names, n => n.Contains(marker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Room_images_accept_2023_enum_name_sort_and_filter()
|
||||
{
|
||||
using var setupClient = Client();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
using var apiClient = Client(player);
|
||||
using var response = await apiClient.GetAsync(
|
||||
"/api/images/v4/room/116?sort=CheerCount_Desc&filter=PublicOnly&take=8&skip=0");
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
|
||||
}
|
||||
|
||||
private HttpClient Client(GameClientSession? session = null, string subdomain = "api")
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and deserialises the
|
||||
/// reply into <c>FGCPNAACHIK</c> — the SAME type <c>GET rooms/{id}</c> returns
|
||||
/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
|
||||
/// <c>Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
|
||||
/// with the route literal on :4802).
|
||||
///
|
||||
/// So the two responses must be shape-identical. When they drift, the server
|
||||
/// still answers 200 and the client throws while reading it — and because the
|
||||
/// clone promise's exception is never observed, it surfaces from the finalizer
|
||||
/// thread seconds later as a bare "Failed to copy room: Exception of type
|
||||
/// '...' was thrown", with nothing in the server log to show for it. That is
|
||||
/// exactly how this failed in the wild: the room WAS created, and the client
|
||||
/// simply never registered it.
|
||||
/// </summary>
|
||||
public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomCloneShapeTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_response_matches_the_get_by_id_shape()
|
||||
{
|
||||
using var client = Client();
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
var sourceId = 9_400_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = sourceId,
|
||||
Name = $"CloneSrc{Guid.NewGuid():N}"[..20],
|
||||
CreatorPlayerId = session.PlayerId,
|
||||
CloningAllowed = true,
|
||||
});
|
||||
// Give the source a real sub-room. Without one, BOTH responses fall
|
||||
// back to the synthesised default sub-room and the comparison never
|
||||
// exercises the branch a real room (like the seeded RecCenter the
|
||||
// room-creation picker clones) actually takes.
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = sourceId,
|
||||
Name = "Home",
|
||||
OrderIndex = 0,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
|
||||
var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||
|
||||
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = expected.Except(actual).Order(StringComparer.Ordinal).ToList();
|
||||
|
||||
// The reader walks the nested sub-room list too, so a key missing there
|
||||
// breaks it just as hard as one missing at the top level.
|
||||
foreach (var list in new[] { "SubRooms", "Scenes" })
|
||||
{
|
||||
if (!getBody.TryGetProperty(list, out var expectedList) ||
|
||||
expectedList.ValueKind != JsonValueKind.Array ||
|
||||
expectedList.GetArrayLength() == 0) continue;
|
||||
|
||||
Assert.True(cloneBody.TryGetProperty(list, out var actualList)
|
||||
&& actualList.ValueKind == JsonValueKind.Array,
|
||||
$"clone response has no {list} array while get-by-id does");
|
||||
Assert.True(actualList.GetArrayLength() > 0,
|
||||
$"clone response has an EMPTY {list} array while get-by-id returns "
|
||||
+ $"{expectedList.GetArrayLength()}; the client's reader requires at least one entry");
|
||||
|
||||
var expectedKeys = expectedList[0].EnumerateObject().Select(p => p.Name);
|
||||
var actualKeys = actualList[0].EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
missing.AddRange(expectedKeys.Where(k => !actualKeys.Contains(k)).Select(k => $"{list}[0].{k}"));
|
||||
}
|
||||
Assert.True(missing.Count == 0,
|
||||
$"""
|
||||
The clone response is missing {missing.Count} key(s) that get-by-id
|
||||
returns. Both deserialize into FGCPNAACHIK, so the client throws while
|
||||
reading the clone and silently never registers the new room:
|
||||
|
||||
{string.Join(Environment.NewLine, missing)}
|
||||
|
||||
clone keys: {string.Join(", ", actual.Order(StringComparer.Ordinal))}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>A clone must own its save data. Cloning used to be a shallow
|
||||
/// copy — the new room's scenes kept pointing at the SOURCE's blob until
|
||||
/// someone saved over them — so every copy of a room shared one object and
|
||||
/// cloning RecCenter produced rooms whose data blob was literally
|
||||
/// <c>room_100_v1.dat</c>. A clone that references another room's blob is
|
||||
/// the bug, whatever the name.</summary>
|
||||
[Fact]
|
||||
public async Task Clone_does_not_reference_the_source_rooms_blob()
|
||||
{
|
||||
// The suite runs with S3 unconfigured, where there is nothing to copy
|
||||
// and the clone deliberately keeps the shallow reference. Swap in a
|
||||
// real (in-memory) store so this exercises the copy path itself.
|
||||
var store = new InMemoryObjectStorage();
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IObjectStorage>();
|
||||
services.AddSingleton<IObjectStorage>(store);
|
||||
}));
|
||||
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
var sourceId = 9_500_000 + Random.Shared.Next(1, 99_999);
|
||||
var sourceBlob = $"room_{sourceId}_v1.dat";
|
||||
var sourceBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob);
|
||||
await store.PutAsync(srcBucket, srcKey, sourceBytes, "application/octet-stream");
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = sourceId,
|
||||
Name = $"BlobSrc{Guid.NewGuid():N}"[..20],
|
||||
CreatorPlayerId = session.PlayerId,
|
||||
CloningAllowed = true,
|
||||
CurrentDataBlobName = sourceBlob,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = sourceId,
|
||||
Name = "Home",
|
||||
OrderIndex = 0,
|
||||
DataBlobName = sourceBlob,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.FirstAsync(r => r.Id == cloneId);
|
||||
var scenes = await db.RoomScenes.Where(s => s.RoomId == cloneId).ToListAsync();
|
||||
|
||||
Assert.False(room.CurrentDataBlobName == sourceBlob,
|
||||
$"the clone's room row still points at the source's blob ({sourceBlob})");
|
||||
foreach (var scene in scenes)
|
||||
Assert.False(scene.DataBlobName == sourceBlob,
|
||||
$"cloned sub-room {scene.OrderIndex} still points at the source's blob ({sourceBlob})");
|
||||
|
||||
// It must be a real copy, not just a cleared reference — the point
|
||||
// is that the player's content comes along.
|
||||
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName),
|
||||
"the clone has no blob at all; the source's data should have been copied, not dropped");
|
||||
var (dstBucket, dstKey) = BlobRouter.Route(room.CurrentDataBlobName);
|
||||
Assert.Equal(sourceBytes, await store.GetAsync(dstBucket, dstKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal in-memory <see cref="IObjectStorage"/>. The suite runs
|
||||
/// with S3 unconfigured, so without this the clone's copy path short-circuits
|
||||
/// and never gets exercised.</summary>
|
||||
private sealed class InMemoryObjectStorage : IObjectStorage
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
public bool IsS3Configured => true;
|
||||
|
||||
public Task<long> PutAsync(
|
||||
string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default)
|
||||
{
|
||||
_objects[$"{bucket}/{key}"] = bytes;
|
||||
return Task.FromResult((long)bytes.Length);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetAsync(string bucket, string key, CancellationToken ct = default) =>
|
||||
Task.FromResult(_objects.TryGetValue($"{bucket}/{key}", out var bytes) ? bytes : null);
|
||||
|
||||
public Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct = default) =>
|
||||
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ReadJsonAsync(
|
||||
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path) { Content = content };
|
||||
using var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"{method} {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage of the 2023 client's in-room shop
|
||||
/// (api/roomconsumables/v1) using the exact request/response wire shapes
|
||||
/// the 2023.03.21 client serialises (verified against the RecNet.Runtime
|
||||
/// formatters in the ISIL dump). The critical regression guard: every
|
||||
/// inventory row returned by <c>/room/{id}/me</c> must carry a non-null
|
||||
/// <c>Consumable</c> desc — RoomConsumablesManager NREs on room join
|
||||
/// otherwise.
|
||||
/// </summary>
|
||||
public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomConsumablesShopTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Full_shop_loop_create_purchase_inventory_consume_delete()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
var buyer = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_200_001L;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"ShopRoom_{Guid.NewGuid():N}"[..24],
|
||||
Description = "shop test room",
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
State = 0,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "community",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var level = scope.ServiceProvider.GetRequiredService<LevelService>();
|
||||
await level.GrantCurrencyAsync(buyer.PlayerId, 2, 100, "test-seed");
|
||||
}
|
||||
|
||||
using var ownerClient = ApiClient(owner);
|
||||
using var buyerClient = ApiClient(buyer);
|
||||
|
||||
// ── Create (client body = the desc DTO itself) ───────────────────
|
||||
var created = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
||||
{
|
||||
"RoomId": {{roomId}},
|
||||
"Name": "Cola",
|
||||
"Description": "A refreshing test beverage",
|
||||
"ImageName": "",
|
||||
"PriceAndCurrency": { "Price": 10, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(0, created.GetProperty("Status").GetInt32());
|
||||
var consumableId = created.GetProperty("Consumable").GetProperty("RoomConsumableId").GetGuid();
|
||||
Assert.NotEqual(Guid.Empty, consumableId);
|
||||
Assert.Equal(roomId, created.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
|
||||
|
||||
// ── Non-owner cannot create in someone else's room ───────────────
|
||||
var forbidden = await PostJsonAsync(buyerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
||||
{ "RoomId": {{roomId}}, "Name": "Hax", "Description": "", "ImageName": "",
|
||||
"PriceAndCurrency": { "Price": 1, "CurrencyId": null } }
|
||||
""");
|
||||
Assert.Equal(7, forbidden.GetProperty("Status").GetInt32()); // PlayerDoesntHavePermission
|
||||
|
||||
// ── Room catalog ─────────────────────────────────────────────────
|
||||
var catalog = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
|
||||
Assert.Equal(1, catalog.GetArrayLength());
|
||||
// Responses carry the price FLAT. The client's consumable formatter
|
||||
// (RecNet.Runtime/FCIBLPCOODP) reads RoomConsumableId/RoomId/Name/
|
||||
// Description/ImageName/Price/PurchaseCurrencyId/ModifiedAt and has no
|
||||
// "PriceAndCurrency" key at all — only REQUEST bodies nest it.
|
||||
Assert.Equal(10, catalog[0].GetProperty("Price").GetInt64());
|
||||
|
||||
// ── Purchase at the wrong price is rejected ──────────────────────
|
||||
var codeA = Guid.NewGuid();
|
||||
var badPrice = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
|
||||
{
|
||||
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
|
||||
"ExpectedPriceAndCurrency": { "Price": 5, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
// OperationResult is an OBJECT { Status, InventoryItem } — the base type
|
||||
// OKHNOPLBOFP holds a single BGHBAILNNJJ, which itself exposes the status
|
||||
// enum plus the inventory row. The scalar token-operation enum lives in
|
||||
// BalanceUpdateResult (Nullable<CABBDKFODEC>) on MJKFEHPIDME.
|
||||
Assert.Equal(6, badPrice.GetProperty("BalanceUpdateResult").GetInt32()); // RequestedPriceDoesNotMatch
|
||||
Assert.Equal(38, badPrice.GetProperty("OperationResult").GetProperty("Status").GetInt32());
|
||||
var balanceBefore = badPrice.GetProperty("TokenBalanceResponse").GetProperty("Balance").GetInt64();
|
||||
|
||||
// ── Purchase (client stores NewConcurrencyCode locally) ──────────
|
||||
var purchase = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
|
||||
{
|
||||
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
|
||||
"ExpectedPriceAndCurrency": { "Price": 10, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(0, purchase.GetProperty("OperationResult").GetProperty("Status").GetInt32());
|
||||
var tokenBalance = purchase.GetProperty("TokenBalanceResponse");
|
||||
Assert.Equal(2, tokenBalance.GetProperty("CurrencyType").GetInt32());
|
||||
Assert.Equal(balanceBefore - 10, tokenBalance.GetProperty("Balance").GetInt64());
|
||||
|
||||
// ── Inventory on room join: Consumable must NEVER be null ────────
|
||||
var inventory = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}/me");
|
||||
Assert.Equal(1, inventory.GetArrayLength());
|
||||
var row = inventory[0];
|
||||
Assert.Equal(consumableId, row.GetProperty("RoomConsumableId").GetGuid());
|
||||
Assert.Equal((int)buyer.PlayerId, row.GetProperty("AccountId").GetInt32());
|
||||
Assert.Equal(1, row.GetProperty("Count").GetInt32());
|
||||
Assert.Equal(codeA, row.GetProperty("ConcurrencyCode").GetGuid());
|
||||
Assert.Equal(JsonValueKind.Object, row.GetProperty("Consumable").ValueKind);
|
||||
Assert.Equal(roomId, row.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
|
||||
|
||||
// ── isOwned: a non-creator holds stock ───────────────────────────
|
||||
using var isOwnedResponse = await ownerClient.GetAsync(
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/isOwned");
|
||||
Assert.Equal("true", await isOwnedResponse.Content.ReadAsStringAsync());
|
||||
|
||||
// ── Consume with a stale concurrency code resyncs the client ─────
|
||||
var codeB = Guid.NewGuid();
|
||||
var staleConsume = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
||||
{ "CurrentConcurrencyCode": "{{Guid.NewGuid()}}", "NewConcurrencyCode": "{{codeB}}" }
|
||||
""");
|
||||
Assert.Equal(32, staleConsume.GetProperty("Status").GetInt32()); // ConcurrencyCodeMismatch
|
||||
Assert.Equal(1, staleConsume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
|
||||
|
||||
// ── Consume with the right code decrements and rotates the code ──
|
||||
var consume = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
||||
{ "CurrentConcurrencyCode": "{{codeA}}", "NewConcurrencyCode": "{{codeB}}" }
|
||||
""");
|
||||
Assert.Equal(0, consume.GetProperty("Status").GetInt32());
|
||||
Assert.Equal(0, consume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
|
||||
Assert.Equal(codeB, consume.GetProperty("InventoryItem").GetProperty("ConcurrencyCode").GetGuid());
|
||||
|
||||
// ── Consuming an empty stack is refused ──────────────────────────
|
||||
var emptyConsume = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
||||
{ "CurrentConcurrencyCode": "{{codeB}}", "NewConcurrencyCode": "{{Guid.NewGuid()}}" }
|
||||
""");
|
||||
Assert.Equal(33, emptyConsume.GetProperty("Status").GetInt32()); // PlayerDoesNotOwnConsumable
|
||||
|
||||
// ── Update via POST with RoomConsumableId in the body ────────────
|
||||
var updated = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
||||
{
|
||||
"RoomConsumableId": "{{consumableId}}",
|
||||
"RoomId": {{roomId}},
|
||||
"Name": "Diet Cola",
|
||||
"Description": "Now with fewer tokens",
|
||||
"ImageName": "",
|
||||
"PriceAndCurrency": { "Price": 5, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(0, updated.GetProperty("Status").GetInt32());
|
||||
Assert.Equal("Diet Cola", updated.GetProperty("Consumable").GetProperty("Name").GetString());
|
||||
Assert.Equal(5, updated.GetProperty("Consumable").GetProperty("Price").GetInt64());
|
||||
|
||||
// ── Delete: buyer refused, owner succeeds, catalog empties ───────
|
||||
using var buyerDelete = await buyerClient.DeleteAsync(
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
|
||||
Assert.Equal("7", await buyerDelete.Content.ReadAsStringAsync()); // PlayerDoesntHavePermission
|
||||
|
||||
using var ownerDelete = await ownerClient.DeleteAsync(
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
|
||||
Assert.Equal("0", await ownerDelete.Content.ReadAsStringAsync());
|
||||
|
||||
var emptied = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
|
||||
Assert.Equal(0, emptied.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unprefixed_2023_commerce_probes_respond()
|
||||
{
|
||||
// The 2023 client calls these WITHOUT the api/ prefix on startup;
|
||||
// a 404 here surfaces as "CleanupPendingTransactions failed" +
|
||||
// unobserved-HTTP-404 crash reports in Player.log.
|
||||
using var client = ApiClient();
|
||||
|
||||
using var cleanup = await client.PostAsync("/purchase/v1/cleanuppending",
|
||||
new StringContent("", Encoding.UTF8, "application/x-www-form-urlencoded"));
|
||||
Assert.True(cleanup.IsSuccessStatusCode);
|
||||
|
||||
using var hasSpent = await client.GetAsync("/purchase/v1/hasspentmoney");
|
||||
Assert.Equal("false", await hasSpent.Content.ReadAsStringAsync());
|
||||
|
||||
var bundles = await GetJsonAsync(client, "/reminder/currentTokenBundles/v2");
|
||||
Assert.Equal(JsonValueKind.Array, bundles.ValueKind);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path, string json)
|
||||
{
|
||||
using var response = await client.PostAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 room-save flow (captured from the 2023.03.21 client against dev,
|
||||
/// 2026-07-07). The client uploads two blobs to storage /upload —
|
||||
/// FileType=6 room metadata, then FileType=1 scene save — and commits
|
||||
/// them with a JSON POST to rooms/{id}/subrooms/{id}/data:
|
||||
/// { "UnityAssetId": null,
|
||||
/// "RoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null },
|
||||
/// "SubRoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null } }
|
||||
/// SubRoomData.Filename is the scene blob and must become the room's
|
||||
/// CurrentDataBlobName.
|
||||
/// </summary>
|
||||
public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomSave2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task SubRoomData_accepts_2023_nested_json_and_commits_blob()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_200_001L;
|
||||
var blobName = $"room_{roomId}_v1_{Guid.NewGuid():N}.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
|
||||
{
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Save2023_{Guid.NewGuid():N}"[..24],
|
||||
Description = "2023 save flow test room",
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.Rooms.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(r => r.CreatorPlayerId, owner.PlayerId));
|
||||
}
|
||||
}
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
var body = new
|
||||
{
|
||||
UnityAssetId = (long?)null,
|
||||
RoomData = new { Filename = "roommeta_test.bin", Hash = (string?)null, OwnershipProof = (string?)null },
|
||||
SubRoomData = new { Filename = blobName, Hash = (string?)null, OwnershipProof = (string?)null },
|
||||
};
|
||||
using var response = await roomsClient.PostAsync(
|
||||
$"/rooms/{roomId}/subrooms/0/data",
|
||||
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
|
||||
|
||||
using var json = JsonDocument.Parse(responseText);
|
||||
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), responseText);
|
||||
|
||||
// 2023 commit-response contract (NEOPBOMGIOG): value carries a
|
||||
// Room and a SubRoomDataSave. The Room reuses the exact GET
|
||||
// rooms/{id} shape (BuildRoomServerDetails) — do NOT bolt extra
|
||||
// keys onto it: the room-details mapper is strict about value
|
||||
// shapes and an invented RankingContext object broke every
|
||||
// room-details parse ("Malformed Response"). See befd590 revert.
|
||||
var value = json.RootElement.GetProperty("value");
|
||||
var roomDto = value.GetProperty("Room");
|
||||
Assert.Equal(JsonValueKind.Object, roomDto.ValueKind);
|
||||
foreach (var key in new[] { "RoomId", "SubRooms", "Roles", "Tags", "Stats" })
|
||||
Assert.True(roomDto.TryGetProperty(key, out _), $"Room missing '{key}': {responseText}");
|
||||
|
||||
var save = value.GetProperty("SubRoomDataSave");
|
||||
Assert.Equal(blobName, save.GetProperty("DataBlob").GetString());
|
||||
foreach (var key in new[] { "SubRoomDataSaveId", "SubRoomId", "UnityAssetId", "SavedByAccountId", "CreatedAt" })
|
||||
Assert.True(save.TryGetProperty(key, out _), $"SubRoomDataSave missing '{key}': {responseText}");
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.AsNoTracking().SingleAsync(r => r.Id == roomId);
|
||||
Assert.Equal(blobName, room.CurrentDataBlobName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_fileType6_room_metadata_is_stored_not_stubbed()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
using var storageClient = ApiClient(player, subdomain: "storage");
|
||||
using var form = new MultipartFormDataContent("BestHTTP_HTTPMultiPartForm_TEST");
|
||||
var file = new ByteArrayContent(new byte[] { 0x0A, 0x02, 0x08, 0x01 });
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
form.Add(file, "File", "file.bin");
|
||||
form.Add(new StringContent("6"), "FileType");
|
||||
|
||||
using var response = await storageClient.PostAsync("/upload", form);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
|
||||
|
||||
using var json = JsonDocument.Parse(responseText);
|
||||
var filename = json.RootElement.GetProperty("filename").GetString();
|
||||
Assert.NotNull(filename);
|
||||
Assert.StartsWith("roommeta_", filename);
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.True(
|
||||
await db.RoomDataBlobs.AnyAsync(b => b.BlobName == filename),
|
||||
$"expected a RoomDataBlobs row for {filename}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression: cloning a room — and then cloning the CLONE — must both
|
||||
/// succeed and return the full room-details shape (FGCPNAACHIK). Previously
|
||||
/// the handler relied on DB auto-increment for the room id AND the copied
|
||||
/// scene ids, which collide with the seeded id range once the identity
|
||||
/// sequence lags → the clone POST 500s and the client boots the player to
|
||||
/// their dorm ("Failed to copy room: Failed to clone room").
|
||||
///
|
||||
/// The first clone goes through the <c>roomserver/</c>-prefixed route:
|
||||
/// the 2023 in-room "copy room" flow (RecNet.Runtime NLDBPDCNNCF) sends
|
||||
/// that prefix like every other room mutation, and the bare-only route
|
||||
/// 404'd with an empty body ("Failed to copy room: Exception of type
|
||||
/// '…' was thrown").
|
||||
///
|
||||
/// Blob rules: cloning a FIRST-PARTY template (AG + system-owned) must
|
||||
/// give the clone a FRESH empty blob — the template's blob is a MakerPen
|
||||
/// overlay against the shared baked scene, not the clone's content.
|
||||
/// Cloning a USER-owned room (even AG-flagged) must carry its blob so
|
||||
/// "copy room" keeps the player's edits.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Cloning_a_room_and_then_the_clone_both_succeed()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_300_777L;
|
||||
var templateBlob = $"room_{roomId}_v1.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
|
||||
{
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"CloneSrc_{Guid.NewGuid():N}"[..20],
|
||||
Description = "clone source",
|
||||
// A first-party RRO/AG base room (RecCenter-like):
|
||||
// system-owned, CloningAllowed=false, yet the base-room
|
||||
// picker must still be able to clone it. Its blob is a
|
||||
// MakerPen overlay that must NOT leak into clones.
|
||||
CreatorPlayerId = PlayerService.SystemAccountId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
CloningAllowed = false,
|
||||
CurrentDataBlobName = templateBlob,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = 0,
|
||||
Name = "Main",
|
||||
DataBlobName = templateBlob,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
|
||||
// First clone: template → player copy, via the roomserver/ prefix the
|
||||
// real client uses.
|
||||
var firstCloneId = await CloneAndAssertAsync(
|
||||
roomsClient, roomId, "FirstClone", pathPrefix: "/roomserver");
|
||||
Assert.NotEqual(roomId, firstCloneId);
|
||||
// A clone of a first-party template used to be left with NO blob, on
|
||||
// the reasoning that its content is baked client geometry. That left
|
||||
// the copy with nothing to load: the CDN synthesises a ~1.8 KB stub for
|
||||
// a name it has nothing stored under, and the client fetches exactly
|
||||
// that through GetRoomData ("/room/{blob}") while copying, then rejects
|
||||
// it. Clones that carried real content joined fine.
|
||||
//
|
||||
// The content now comes across. Sharing the SOURCE's object is still
|
||||
// wrong, but that is prevented by copying it under the clone's own name
|
||||
// rather than by having no blob at all — see
|
||||
// RoomCloneShapeTests.Clone_does_not_reference_the_source_rooms_blob,
|
||||
// which runs with a real object store. This suite has none, so the
|
||||
// copy deliberately falls back to keeping the reference and only the
|
||||
// "has content" half is checkable here.
|
||||
await AssertBlobsAsync(firstCloneId, expectEmpty: false,
|
||||
"a clone of a first-party template must carry its content");
|
||||
|
||||
// Simulate the player saving MakerPen edits into their clone.
|
||||
var editedBlob = $"room_{firstCloneId}_edit_{Guid.NewGuid():N}.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.SingleAsync(r => r.Id == firstCloneId);
|
||||
room.CurrentDataBlobName = editedBlob;
|
||||
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == firstCloneId);
|
||||
scene.DataBlobName = editedBlob;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// The regression: cloning the clone (bare path still works too).
|
||||
// The source is user-owned — even though it inherited IsAGRoom, the
|
||||
// player's edits must carry over.
|
||||
var secondCloneId = await CloneAndAssertAsync(roomsClient, firstCloneId, "SecondClone");
|
||||
Assert.NotEqual(firstCloneId, secondCloneId);
|
||||
await AssertBlobsAsync(secondCloneId, expectEmpty: false,
|
||||
"copying a user-owned room must keep its blob (player edits)");
|
||||
}
|
||||
|
||||
private async Task AssertBlobsAsync(long roomId, bool expectEmpty, string why)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.SingleAsync(r => r.Id == roomId);
|
||||
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == roomId);
|
||||
if (expectEmpty)
|
||||
{
|
||||
Assert.True(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was '{room.CurrentDataBlobName}'");
|
||||
Assert.True(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was '{scene.DataBlobName}'");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was empty");
|
||||
Assert.False(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was empty");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<long> CloneAndAssertAsync(
|
||||
HttpClient client, long sourceId, string name, string pathPrefix = "")
|
||||
{
|
||||
// The real 2023 client posts the name as x-www-form-urlencoded
|
||||
// (`name=...`), NOT JSON — testing with JSON would miss the 415 that
|
||||
// a [FromBody] handler returns for a form POST.
|
||||
using var resp = await client.PostAsync(
|
||||
$"{pathPrefix}/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = name }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var root = json.RootElement;
|
||||
// Full room-details shape (FGCPNAACHIK), not a status wrapper.
|
||||
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
|
||||
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
|
||||
return root.GetProperty("RoomId").GetInt64();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression: <c>GET rooms/{id}/subrooms/{sub}/saves</c> must return the
|
||||
/// paged wrapper <c>{"Results":[…],"TotalResults":N}</c>
|
||||
/// (PagedResultsDTO<SubRoomDataSaveDTO>), NOT a bare array. The 2023
|
||||
/// client's GetSubRoomDataSaves strict reader throws
|
||||
/// <c>expected:'{', actual:'['</c> on an array, which aborts the whole
|
||||
/// calling flow — room clone fails with the message-less "Failed to copy
|
||||
/// room" and the private-instance button silently does nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubRoom_saves_returns_paged_object_not_bare_array()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_300_888L;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
|
||||
{
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"SavesShape_{Guid.NewGuid():N}"[..20],
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
CurrentDataBlobName = $"room_{roomId}_v1.dat",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = 0,
|
||||
Name = "Home",
|
||||
DataBlobName = $"room_{roomId}_v1.dat",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
foreach (var path in new[]
|
||||
{
|
||||
$"/roomserver/rooms/{roomId}/subrooms/0/saves?skip=0&take=10",
|
||||
$"/rooms/{roomId}/subrooms/0/saves",
|
||||
$"/roomserver/rooms/{roomId}/subrooms/0/saves/no_unity_assets?skip=0&take=10",
|
||||
})
|
||||
{
|
||||
using var resp = await roomsClient.GetAsync(path);
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"{path} -> {(int)resp.StatusCode}: {text}");
|
||||
using var json = JsonDocument.Parse(text);
|
||||
Assert.Equal(JsonValueKind.Object, json.RootElement.ValueKind);
|
||||
Assert.Equal(JsonValueKind.Array, json.RootElement.GetProperty("Results").ValueKind);
|
||||
Assert.True(json.RootElement.GetProperty("TotalResults").GetInt64() >= 1, $"TotalResults missing/zero: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class RoomServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToWireRoom_never_emits_empty_image_name()
|
||||
{
|
||||
var room = new RoomEntity
|
||||
{
|
||||
Id = 1001,
|
||||
Name = "CustomRoomWithoutImage",
|
||||
Description = "Custom room",
|
||||
ImageName = "",
|
||||
Accessibility = 1,
|
||||
State = 0,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(RoomService.ToWireRoom(room));
|
||||
using var document = JsonDocument.Parse(json);
|
||||
|
||||
Assert.Equal(RoomService.DefaultRoomImageName, document.RootElement.GetProperty("ImageName").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDisplayImageName_preserves_existing_image_name()
|
||||
{
|
||||
var room = new RoomEntity
|
||||
{
|
||||
Name = "Paintball",
|
||||
ImageName = "custom-thumbnail.png",
|
||||
};
|
||||
|
||||
Assert.Equal("custom-thumbnail.png", RoomService.ResolveDisplayImageName(room));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class RoomsVisitedByTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomsVisitedByTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Continue_rooms_hide_private_rooms_from_non_owners()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
var visitor = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var publicRoomId = 9_100_001L;
|
||||
var privateRoomId = 9_100_002L;
|
||||
var publicRoomName = $"ContinuePublic_{suffix}";
|
||||
var privateRoomName = $"ContinuePrivate_{suffix}";
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.AddRange(
|
||||
BuildRoom(publicRoomId, publicRoomName, owner.PlayerId, accessibility: 1),
|
||||
BuildRoom(privateRoomId, privateRoomName, owner.PlayerId, accessibility: 0));
|
||||
db.RoomVisits.AddRange(
|
||||
BuildVisit(publicRoomId, visitor.PlayerId, now.AddMinutes(-2)),
|
||||
BuildVisit(privateRoomId, visitor.PlayerId, now.AddMinutes(-1)),
|
||||
BuildVisit(privateRoomId, owner.PlayerId, now));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var visitorClient = ApiClient(visitor);
|
||||
var visitorNames = await GetContinueRoomNamesAsync(visitorClient);
|
||||
|
||||
Assert.Contains(publicRoomName, visitorNames);
|
||||
Assert.DoesNotContain(privateRoomName, visitorNames);
|
||||
|
||||
using var ownerClient = ApiClient(owner);
|
||||
var ownerNames = await GetContinueRoomNamesAsync(ownerClient);
|
||||
|
||||
Assert.Contains(privateRoomName, ownerNames);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<string[]> GetContinueRoomNamesAsync(HttpClient client)
|
||||
{
|
||||
using var response = await client.GetAsync("/rooms/visitedby/me");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync();
|
||||
using var document = await JsonDocument.ParseAsync(body);
|
||||
return document.RootElement
|
||||
.EnumerateArray()
|
||||
.Select(room => room.GetProperty("Name").GetString() ?? string.Empty)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static RoomEntity BuildRoom(long id, string name, long creatorPlayerId, int accessibility) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = name,
|
||||
CreatorPlayerId = creatorPlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
State = 0,
|
||||
Accessibility = accessibility,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
HiddenFromBrowse = false,
|
||||
TagsCsv = "community",
|
||||
};
|
||||
|
||||
private static RoomVisitEntity BuildVisit(long roomId, long playerId, DateTime visitedAt) => new()
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = playerId,
|
||||
FirstVisitAt = visitedAt,
|
||||
LastVisitAt = visitedAt,
|
||||
VisitCount = 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the two ways a column reaches a database that already exists, because
|
||||
/// getting either one wrong is invisible until a live server starts returning
|
||||
/// 500s.
|
||||
///
|
||||
/// Every other test in this suite runs against a FRESH database, where
|
||||
/// <c>EnsureCreated</c> builds the whole schema straight from the entity model —
|
||||
/// so every column always exists and none of this is exercised. Only an
|
||||
/// *existing* database can be missing a column, and that is exactly what took
|
||||
/// down club home, matchmake/dorm, the presence heartbeat and the chat inbox at
|
||||
/// once: three columns were added to the entity model with an EF migration, but
|
||||
/// the Postgres path never replays migrations, so a deployed database never got
|
||||
/// them.
|
||||
///
|
||||
/// The two mechanisms, and the invariant each test asserts:
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item><b>SQLite</b> applies committed EF migrations via <c>Database.Migrate()</c>.
|
||||
/// So the migration history alone must be able to build the current model.</item>
|
||||
/// <item><b>Postgres</b> is <c>EnsureCreated</c>-only and NEVER replays
|
||||
/// migrations. A database created before a column existed only gets it from an
|
||||
/// idempotent <c>Ensure*</c> ALTER in <see cref="DorkNet.Server.Startup"/>'s
|
||||
/// DatabaseBootstrap. So anything added after the Initial migration must appear
|
||||
/// there too.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class SchemaBootstrapParityTests
|
||||
{
|
||||
/// <summary>Every table and column in the entity model must be reachable on
|
||||
/// a database that already exists — via a committed migration, an
|
||||
/// idempotent <c>Ensure*</c> patch, or both.
|
||||
///
|
||||
/// That union is exactly what boot does: SQLite runs <c>Database.Migrate()</c>
|
||||
/// and then the <c>Ensure*</c> helpers, and Postgres runs the helpers alone.
|
||||
/// A property with NEITHER reaches only brand-new databases, and every
|
||||
/// query touching it throws at runtime on a deployed server.
|
||||
///
|
||||
/// The union is asserted rather than migrations alone because a large amount
|
||||
/// of the schema legitimately predates this rule and lives only in the
|
||||
/// helpers. Tightening that up is a separate cleanup; what must never happen
|
||||
/// is a column covered by nothing.</summary>
|
||||
[Fact]
|
||||
public async Task Every_model_column_reaches_an_existing_database()
|
||||
{
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DorkNetDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var db = new DorkNetDbContext(options);
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
var actual = await ReadSqliteSchemaAsync(connection);
|
||||
var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: false);
|
||||
var missing = new List<string>();
|
||||
|
||||
foreach (var entity in db.Model.GetEntityTypes())
|
||||
{
|
||||
var table = entity.GetTableName();
|
||||
if (string.IsNullOrEmpty(table)) continue;
|
||||
|
||||
var migrated = actual.TryGetValue(table, out var columns);
|
||||
if (!migrated && !patchedTables.Contains(table))
|
||||
{
|
||||
missing.Add($"{table} (whole table)");
|
||||
continue;
|
||||
}
|
||||
// A table the helpers create carries its own columns in the same
|
||||
// CREATE, so only migrated tables need a per-column check.
|
||||
if (!migrated) continue;
|
||||
|
||||
foreach (var property in entity.GetProperties())
|
||||
{
|
||||
var column = property.GetColumnName();
|
||||
if (string.IsNullOrEmpty(column)) continue;
|
||||
if (columns!.Contains(column)) continue;
|
||||
if (patchedColumns.Contains($"{table}.{column}")) continue;
|
||||
missing.Add($"{table}.{column}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
$"""
|
||||
{missing.Count} model table(s)/column(s) are covered by NOTHING.
|
||||
|
||||
They exist on a freshly-created database (EnsureCreated builds the
|
||||
whole model) but never appear on one that already exists, so a
|
||||
deployed server throws "no such column" / "column does not exist"
|
||||
on every request that touches them.
|
||||
|
||||
Add a migration AND an idempotent ALTER in an Ensure*Async helper in
|
||||
DorkNet.Server/Startup/DatabaseBootstrap.cs for:
|
||||
|
||||
{string.Join(Environment.NewLine, missing.Order(StringComparer.Ordinal))}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Every column added AFTER the Initial migration must also be
|
||||
/// added by an idempotent Postgres <c>ALTER</c> in DatabaseBootstrap.
|
||||
///
|
||||
/// Postgres never replays migrations, so a migration alone reaches only
|
||||
/// brand-new databases. A production database created before the column
|
||||
/// existed gets it from the Ensure* helpers or not at all — the exact hole
|
||||
/// that broke matchmake/dorm (PrivateInstances.RoomCode),
|
||||
/// club/home/me (Clubs.ClubChatEnabled) and the chat inbox
|
||||
/// (ChatMessages.ModerationState).</summary>
|
||||
[Fact]
|
||||
public void Post_initial_columns_are_patched_for_existing_postgres_databases()
|
||||
{
|
||||
var migrations = FindRepoDirectory("DorkNet.Server", "Migrations");
|
||||
var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: true);
|
||||
|
||||
// Anything AddColumn'd by a migration other than Initial post-dates the
|
||||
// baseline, so an existing Postgres database will not have it.
|
||||
var gaps = new List<string>();
|
||||
foreach (var file in Directory.GetFiles(migrations, "*.cs"))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(file);
|
||||
if (name.Contains("Designer", StringComparison.Ordinal)) continue;
|
||||
if (name.Contains("Initial", StringComparison.Ordinal)) continue;
|
||||
if (name.Contains("ModelSnapshot", StringComparison.Ordinal)) continue;
|
||||
|
||||
var text = File.ReadAllText(file);
|
||||
foreach (Match m in Regex.Matches(text,
|
||||
@"AddColumn<[^>]+>\(\s*name:\s*""(?<column>\w+)""\s*,\s*table:\s*""(?<table>\w+)""",
|
||||
RegexOptions.Singleline))
|
||||
{
|
||||
var table = m.Groups["table"].Value;
|
||||
var column = m.Groups["column"].Value;
|
||||
if (patchedTables.Contains(table)) continue; // whole table is created by a patch
|
||||
if (patchedColumns.Contains($"{table}.{column}")) continue;
|
||||
gaps.Add($"{table}.{column} (added by {name})");
|
||||
}
|
||||
|
||||
foreach (Match m in Regex.Matches(text,
|
||||
@"CreateTable\(\s*name:\s*""(?<table>\w+)""",
|
||||
RegexOptions.Singleline))
|
||||
{
|
||||
var table = m.Groups["table"].Value;
|
||||
if (!patchedTables.Contains(table))
|
||||
gaps.Add($"{table} (whole table, created by {name})");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(gaps.Count == 0,
|
||||
$"""
|
||||
{gaps.Count} schema change(s) reach new databases only.
|
||||
|
||||
The Postgres path is EnsureCreated-only and never replays
|
||||
migrations, so a migration alone does NOT add these to a database
|
||||
that already exists — a deployed server will throw
|
||||
"column does not exist" on every request that touches them.
|
||||
|
||||
Add an idempotent ALTER/CREATE to an Ensure*Async helper in
|
||||
DorkNet.Server/Startup/DatabaseBootstrap.cs for:
|
||||
|
||||
{string.Join(Environment.NewLine, gaps.Order(StringComparer.Ordinal))}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Tables and columns the <c>Ensure*</c> helpers add by raw SQL.
|
||||
///
|
||||
/// The helpers are private, so this reads their statements out of the source
|
||||
/// rather than executing them. In that source the SQL sits inside verbatim
|
||||
/// strings, so a quoted identifier appears as two literal quote characters
|
||||
/// (<c>""Playlists""</c>).
|
||||
///
|
||||
/// <paramref name="postgresOnly"/> keys off <c>IF NOT EXISTS</c>: the
|
||||
/// Postgres branch uses it, while the SQLite branch omits it and swallows
|
||||
/// the duplicate-column error instead. So requiring it isolates the branch
|
||||
/// that an existing Postgres database actually depends on.</summary>
|
||||
private static (HashSet<string> Tables, HashSet<string> Columns) ReadBootstrapPatches(
|
||||
bool postgresOnly)
|
||||
{
|
||||
var bootstrap = Path.Combine(
|
||||
FindRepoDirectory("DorkNet.Server", "Startup"), "DatabaseBootstrap.cs");
|
||||
Assert.True(File.Exists(bootstrap), $"missing {bootstrap}");
|
||||
var sql = File.ReadAllText(bootstrap);
|
||||
|
||||
// The source mixes both C# string styles, so a quoted identifier shows
|
||||
// up as either two quote characters (verbatim, @"... ""X"" ...") or one
|
||||
// (raw, """... "X" ..."""). Accept either.
|
||||
const string Q = "\"{1,2}";
|
||||
var ifNotExists = postgresOnly ? @"IF\s+NOT\s+EXISTS\s+" : @"(?:IF\s+NOT\s+EXISTS\s+)?";
|
||||
|
||||
var columns = Regex
|
||||
.Matches(sql,
|
||||
$@"ALTER\s+TABLE\s+{Q}(?<table>\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}(?<column>\w+){Q}",
|
||||
RegexOptions.IgnoreCase)
|
||||
.Select(m => $"{m.Groups["table"].Value}.{m.Groups["column"].Value}")
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tables = Regex
|
||||
.Matches(sql,
|
||||
$@"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+{Q}(?<table>\w+){Q}",
|
||||
RegexOptions.IgnoreCase)
|
||||
.Select(m => m.Groups["table"].Value)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Some helpers loop a column-name array through one interpolated ALTER
|
||||
// (EnsureGameRewardColumnsAsync does). The statement alone only shows
|
||||
// "{col}", so pair each such ALTER with the nearest preceding
|
||||
// `new[] { "A", "B" }` and expand it.
|
||||
foreach (Match alter in Regex.Matches(sql,
|
||||
$@"ALTER\s+TABLE\s+{Q}(?<table>\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}\{{(?<var>\w+)\}}{Q}",
|
||||
RegexOptions.IgnoreCase))
|
||||
{
|
||||
var loop = Regex.Matches(
|
||||
sql[..alter.Index],
|
||||
$@"foreach\s*\(\s*var\s+{Regex.Escape(alter.Groups["var"].Value)}\s+in\s+new\[\]\s*\{{(?<items>[^}}]*)\}}")
|
||||
.Cast<Match>()
|
||||
.LastOrDefault();
|
||||
if (loop is null) continue;
|
||||
|
||||
foreach (Match item in Regex.Matches(loop.Groups["items"].Value, "\"(?<name>\\w+)\""))
|
||||
columns.Add($"{alter.Groups["table"].Value}.{item.Groups["name"].Value}");
|
||||
}
|
||||
|
||||
return (tables, columns);
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, HashSet<string>>> ReadSqliteSchemaAsync(
|
||||
SqliteConnection connection)
|
||||
{
|
||||
var schema = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tables = new List<string>();
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText =
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';";
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync()) tables.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
foreach (var table in tables)
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"PRAGMA table_info(\"{table}\");";
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync()) columns.Add(reader.GetString(1));
|
||||
schema[table] = columns;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// <summary>Walk up from the test binaries to the repo root, then into the
|
||||
/// requested source directory.</summary>
|
||||
private static string FindRepoDirectory(params string[] segments)
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "DorkNet.sln")))
|
||||
dir = dir.Parent;
|
||||
|
||||
Assert.NotNull(dir);
|
||||
var path = Path.Combine(new[] { dir!.FullName }.Concat(segments).ToArray());
|
||||
Assert.True(Directory.Exists(path), $"missing source directory {path}");
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Max players is a PER SUB-ROOM setting, adjustable from two places:
|
||||
///
|
||||
/// in-game PUT rooms/{id}/subrooms/{sub}/maxplayers (owner only — the
|
||||
/// "Max Player Count in This Subroom" slider)
|
||||
/// admin PUT api/admin/v1/rooms/{id}/subrooms/{sub}/maxplayers
|
||||
///
|
||||
/// Both write the same <see cref="RoomSceneEntity.MaxPlayers"/>, so a change
|
||||
/// made in one place has to be visible from the other. That is the property
|
||||
/// worth pinning: two independent write paths onto one column drift silently,
|
||||
/// and the symptom (a slider that snaps back) looks like a client bug.
|
||||
///
|
||||
/// Kept distinct from the room's own <see cref="RoomEntity.MaxCapacity"/>,
|
||||
/// which is what the ROOM advertises and what matchmaking hands to
|
||||
/// RoomInstance.MaxCapacity. Reading the room-level figure off sub-room 0
|
||||
/// would collapse the two settings into one.
|
||||
/// </summary>
|
||||
public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public SubRoomMaxPlayersTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_and_admin_edit_the_same_per_subroom_value()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_700_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Caps{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
MaxCapacity = 12,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Arena", OrderIndex = 1, MaxPlayers = 8 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ── in-game: the owner's slider on sub-room 1 only ──────────────────
|
||||
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
|
||||
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
|
||||
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
|
||||
// most of its siblings use. Testing with a form would pass while the
|
||||
// real client's body went unread.
|
||||
using var ownerClient = Client("rooms", owner);
|
||||
using var slider = await ownerClient.PutAsJsonAsync(
|
||||
$"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
|
||||
var sliderBody = await slider.Content.ReadAsStringAsync();
|
||||
Assert.True(slider.IsSuccessStatusCode,
|
||||
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
|
||||
|
||||
// The client deserialises the reply as FGCPNAACHIK — the room-details
|
||||
// shape — so the slider's own response has to carry the new value.
|
||||
var echoed = JsonDocument.Parse(sliderBody).RootElement
|
||||
.GetProperty("SubRooms").EnumerateArray()
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
|
||||
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
|
||||
|
||||
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
|
||||
using var adminClient = Client("admin", admin);
|
||||
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var subRooms = listed.EnumerateArray()
|
||||
.ToDictionary(e => e.GetProperty("subRoomId").GetInt32(),
|
||||
e => e.GetProperty("maxPlayers").GetInt32());
|
||||
|
||||
Assert.Equal(24, subRooms[1]);
|
||||
Assert.Equal(8, subRooms[0]);
|
||||
|
||||
using var adminSet = await adminClient.PutAsJsonAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 40 });
|
||||
Assert.True(adminSet.IsSuccessStatusCode,
|
||||
$"admin PUT maxplayers -> {(int)adminSet.StatusCode}: {await adminSet.Content.ReadAsStringAsync()}");
|
||||
|
||||
// ── the room's own details carry each sub-room's own figure ─────────
|
||||
var details = await GetJsonAsync(ownerClient, $"/rooms/{roomId}");
|
||||
var wire = details.GetProperty("SubRooms").EnumerateArray()
|
||||
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
|
||||
e => e.GetProperty("MaxPlayers").GetInt32());
|
||||
|
||||
Assert.Equal(40, wire[0]);
|
||||
Assert.Equal(24, wire[1]);
|
||||
|
||||
// The room-level figure is the room's advertised capacity, NOT a copy
|
||||
// of sub-room 0 — otherwise the admin edit above would have moved it.
|
||||
Assert.Equal(12, details.GetProperty("MaxPlayers").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_cap_is_clamped_to_a_joinable_range()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_800_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Clamp{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var adminClient = Client("admin", admin);
|
||||
|
||||
// 0 would make the sub-room unjoinable.
|
||||
using var tooLow = await adminClient.PutAsJsonAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 0 });
|
||||
var low = JsonDocument.Parse(await tooLow.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(1, low.GetProperty("maxPlayers").GetInt32());
|
||||
|
||||
using var tooHigh = await adminClient.PutAsJsonAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 9999 });
|
||||
var high = JsonDocument.Parse(await tooHigh.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(80, high.GetProperty("maxPlayers").GetInt32());
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Event notifications (cheers) now go through the REAL Message channel
|
||||
/// (persisted MessageEntity + MessageReceived push with the client's
|
||||
/// Message DTO shape), instead of the old blank-account misuse of
|
||||
/// SubscriptionUpdateProfile. A cheer must land in the recipient's message
|
||||
/// feed as a PlayerCheer (Type 50) from the cheerer.
|
||||
/// </summary>
|
||||
public sealed class SystemNotificationTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private const int PlayerCheerType = 50;
|
||||
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public SystemNotificationTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Cheering_a_player_creates_a_PlayerCheer_message_notification()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var cheerer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
using var client = ApiClient(cheerer);
|
||||
// Real client field names (RecNet.Runtime KFJKDMGHKHE.EMKBFCLIOGN):
|
||||
// PlayerIdTo + CheerCategory (+ RoomId, Anonymous). The old test used
|
||||
// TargetAccountId/Type, which matched the buggy handler binding but
|
||||
// NOT the game client — so it passed while cheers were broken live.
|
||||
using var resp = await client.PostAsync("/api/PlayerCheer/v1/create",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["PlayerIdTo"] = target.PlayerId.ToString(),
|
||||
["CheerCategory"] = "0",
|
||||
["Anonymous"] = "false",
|
||||
}));
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var msg = await db.Messages.FirstOrDefaultAsync(m =>
|
||||
m.RecipientPlayerId == target.PlayerId && m.Type == PlayerCheerType);
|
||||
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal(cheerer.PlayerId, msg!.SenderPlayerId);
|
||||
// No orphan/blank-account row was written anywhere in the flow.
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// QA test cases linked to GitHub issues.
|
||||
///
|
||||
/// The link lives in <see cref="TestCaseEntity.JiraBugUrl"/> — the field Rec
|
||||
/// Room's own tooling used for the bug filed against a failing case — so no
|
||||
/// column is added and the Postgres "migrations never replay" trap is avoided
|
||||
/// entirely.
|
||||
///
|
||||
/// GitHub itself is faked. These tests are about our behaviour around it:
|
||||
/// filing once rather than per sweep, following issue state in the one
|
||||
/// direction that is safe, and degrading rather than 500ing when no token is
|
||||
/// configured.
|
||||
/// </summary>
|
||||
public sealed class TestCaseGitHubIssueTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public TestCaseGitHubIssueTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
private const int StatusClaimed = 1;
|
||||
private const int StatusFailed = 2;
|
||||
private const int StatusPassed = 3;
|
||||
|
||||
[Fact]
|
||||
public async Task Filing_is_idempotent_and_stores_the_issue_url()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var first = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
var firstBody = await first.Content.ReadAsStringAsync();
|
||||
Assert.True(first.IsSuccessStatusCode, $"first file -> {(int)first.StatusCode}: {firstBody}");
|
||||
Assert.True(JsonDocument.Parse(firstBody).RootElement.GetProperty("created").GetBoolean());
|
||||
|
||||
// The link must be persisted, and in the field the admin UI reads.
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
|
||||
Assert.Contains("/issues/", tc.JiraBugUrl);
|
||||
}
|
||||
|
||||
// Re-running a sweep over a failing pass must not file the bug again.
|
||||
// Measured as a DELTA rather than an absolute count: the fake is shared
|
||||
// with whatever else the server does during the test, and the property
|
||||
// under test is "this call filed nothing", not "nothing was ever filed".
|
||||
var filedBefore = github.Created;
|
||||
using var second = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
var secondBody = await second.Content.ReadAsStringAsync();
|
||||
Assert.True(second.IsSuccessStatusCode, $"second file -> {(int)second.StatusCode}: {secondBody}");
|
||||
Assert.False(JsonDocument.Parse(secondBody).RootElement.GetProperty("created").GetBoolean(),
|
||||
"filing twice created a duplicate issue");
|
||||
Assert.Equal(filedBefore, github.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_closed_issue_marks_the_failing_case_passed()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
Assert.True(filed.IsSuccessStatusCode);
|
||||
|
||||
github.CloseAll();
|
||||
using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(sync.IsSuccessStatusCode, $"sync -> {(int)sync.StatusCode}");
|
||||
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
|
||||
|
||||
// A closed issue means the bug is fixed, so the case that was failing
|
||||
// on it passes.
|
||||
Assert.Equal(StatusPassed, tc.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reopened_issue_marks_the_case_failed_again()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
Assert.True(filed.IsSuccessStatusCode);
|
||||
|
||||
github.CloseAll();
|
||||
using var closeSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(closeSync.IsSuccessStatusCode);
|
||||
|
||||
github.ReopenAll();
|
||||
using var reopenSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(reopenSync.IsSuccessStatusCode);
|
||||
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.Equal(StatusFailed, (await db.TestCases.SingleAsync(c => c.Id == caseId)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_sweep_does_not_stomp_a_tester_mid_claim()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
Assert.True(filed.IsSuccessStatusCode);
|
||||
|
||||
// Someone picks the case up while the issue is still open.
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.TestCases.SingleAsync(c => c.Id == caseId)).Status = StatusClaimed;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(sync.IsSuccessStatusCode);
|
||||
|
||||
await using var check = factory.Services.CreateAsyncScope();
|
||||
var db2 = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var tc = await db2.TestCases.SingleAsync(c => c.Id == caseId);
|
||||
|
||||
// An open issue against a claimed case says nothing new; flipping it to
|
||||
// Failed would yank it out from under whoever is testing it. Only
|
||||
// Failed<->Passed are the reconciler's to move.
|
||||
Assert.Equal(StatusClaimed, tc.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_token_the_endpoint_says_so_instead_of_failing()
|
||||
{
|
||||
// The default fixture has no GitHub configuration, which is what an
|
||||
// ordinary deployment looks like.
|
||||
using var client = await AdminClientAsync(_factory);
|
||||
var caseId = await SeedCaseAsync(_factory, StatusFailed);
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
$"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> WithGitHub(IGitHubIssues github) =>
|
||||
_factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IGitHubIssues>();
|
||||
services.AddSingleton(github);
|
||||
}));
|
||||
|
||||
private async Task<HttpClient> AdminClientAsync(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == session.PlayerId)).IsAdmin = true;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<string> SeedCaseAsync(WebApplicationFactory<Program> factory, int status)
|
||||
{
|
||||
var id = $"tc-{Guid.NewGuid():N}";
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.TestCases.Add(new TestCaseEntity
|
||||
{
|
||||
Id = id,
|
||||
Key = $"QA-{Random.Shared.Next(1000, 9999)}",
|
||||
Title = "Dorm fails to load after cloning a room",
|
||||
Description = "Clone RecCenter, then return to the dorm.",
|
||||
RoomName = "Dorm",
|
||||
Status = status,
|
||||
TagsJson = """["dorm","regression"]""",
|
||||
CommentsJson = """["1811750|2026-07-28T08:00:00Z|Reproduced twice"]""",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>An in-memory GitHub. Records what was filed so the tests can
|
||||
/// assert we did not file twice, and lets them flip issue state.</summary>
|
||||
private sealed class FakeGitHub : IGitHubIssues
|
||||
{
|
||||
private readonly Dictionary<int, GitHubIssue> _issues = [];
|
||||
private int _next = 1;
|
||||
|
||||
public bool IsConfigured => true;
|
||||
|
||||
public string Repository => "DorkSquadRR/DorkNet";
|
||||
|
||||
public int Created { get; private set; }
|
||||
|
||||
public Task<GitHubIssue?> CreateAsync(
|
||||
string title, string body, IEnumerable<string> labels, CancellationToken ct = default)
|
||||
{
|
||||
var number = _next++;
|
||||
var issue = new GitHubIssue(
|
||||
number, $"https://github.com/{Repository}/issues/{number}", "open", title);
|
||||
_issues[number] = issue;
|
||||
Created++;
|
||||
return Task.FromResult<GitHubIssue?>(issue);
|
||||
}
|
||||
|
||||
public Task<GitHubIssue?> GetAsync(int number, CancellationToken ct = default) =>
|
||||
Task.FromResult(_issues.TryGetValue(number, out var issue) ? issue : null);
|
||||
|
||||
public Task<bool> CommentAsync(int number, string body, CancellationToken ct = default) =>
|
||||
Task.FromResult(_issues.ContainsKey(number));
|
||||
|
||||
public void CloseAll() => SetState("closed");
|
||||
|
||||
public void ReopenAll() => SetState("open");
|
||||
|
||||
private void SetState(string state)
|
||||
{
|
||||
foreach (var number in _issues.Keys.ToList())
|
||||
_issues[number] = _issues[number] with { State = state };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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 March_2023_build_is_valid_with_minimal_service_config()
|
||||
{
|
||||
var controller = CreateController();
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.Check("20230321", "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)
|
||||
{
|
||||
"march_2023_03_21",
|
||||
},
|
||||
"march_2023_03_21");
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 client's watch Shop tab pipeline: rows come from
|
||||
/// <c>/api/storefronts/v3/giftdropstore/{id}</c> (each row MUST carry a
|
||||
/// singular <c>GiftDrop</c> object — the 2023 POGBAGAHGIA formatter
|
||||
/// ignores the 2020 <c>GiftDrops</c> array, and a null inner desc makes
|
||||
/// StoreItemListModel's filter predicate NRE so the Shop shows nothing),
|
||||
/// and the featured strip ids come from <c>/v1/toptoday</c> as a BARE
|
||||
/// int list that must resolve against those rows.
|
||||
/// </summary>
|
||||
public sealed class WatchShop2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public WatchShop2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Giftdropstore_rows_carry_singular_giftdrop_and_toptoday_ids_resolve()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var rows = storefront.GetProperty("StoreItems");
|
||||
Assert.True(rows.GetArrayLength() > 0, "watch storefront seeded no rows");
|
||||
|
||||
var rowIds = new HashSet<int>();
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
rowIds.Add(row.GetProperty("PurchasableItemId").GetInt32());
|
||||
Assert.Equal(0, row.GetProperty("Type").GetInt32());
|
||||
Assert.True(row.GetProperty("Prices").GetArrayLength() > 0);
|
||||
|
||||
var giftDrop = row.GetProperty("GiftDrop");
|
||||
Assert.Equal(JsonValueKind.Object, giftDrop.ValueKind);
|
||||
Assert.Equal(row.GetProperty("PurchasableItemId").GetInt32(),
|
||||
giftDrop.GetProperty("GiftDropId").GetInt32());
|
||||
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("FriendlyName").ValueKind);
|
||||
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("TagList").ValueKind);
|
||||
var rarity = giftDrop.GetProperty("Rarity").GetInt32();
|
||||
Assert.Contains(rarity, new[] { 0, 10, 20, 30, 50 });
|
||||
|
||||
// Int32 on the 2023 wire — a descriptor STRING here makes the
|
||||
// strict Utf8Json reader throw "Malformed Response" for the
|
||||
// whole storefront payload (expected:'Number Token').
|
||||
Assert.Equal(JsonValueKind.Number, giftDrop.GetProperty("AvatarItemId").ValueKind);
|
||||
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("AvatarItemDesc").ValueKind);
|
||||
}
|
||||
|
||||
// Featured strip: bare int ids, every one resolvable in the rows.
|
||||
var topToday = await GetJsonAsync(client, "/api/storefronts/v1/toptoday");
|
||||
Assert.Equal(JsonValueKind.Array, topToday.ValueKind);
|
||||
Assert.True(topToday.GetArrayLength() > 0, "toptoday returned no featured ids");
|
||||
foreach (var id in topToday.EnumerateArray())
|
||||
{
|
||||
Assert.Equal(JsonValueKind.Number, id.ValueKind);
|
||||
Assert.Contains(id.GetInt32(), rowIds);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Storefront_includes_food_consumables_with_literal_desc()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var rows = storefront.GetProperty("StoreItems");
|
||||
|
||||
// Food / KO / camera consumables surface with the client's literal
|
||||
// ConsumableItemDesc and no avatar item — that's what routes them to
|
||||
// the Shop's Consumables tab and binds the baked prefab. (Hair dyes
|
||||
// are a hybrid: they also carry an AvatarItemDesc colour guid, so
|
||||
// they're excluded from the no-avatar-item check.)
|
||||
var byDesc = new Dictionary<string, JsonElement>();
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
var gd = row.GetProperty("GiftDrop");
|
||||
var desc = gd.GetProperty("ConsumableItemDesc").GetString() ?? "";
|
||||
if (desc.Length > 0) byDesc[desc] = gd;
|
||||
}
|
||||
|
||||
foreach (var key in new[] { "[FoodConsumable_RootBeer]", "[KOConsumable_Cola]" })
|
||||
{
|
||||
Assert.True(byDesc.TryGetValue(key, out var gd), $"missing consumable {key}");
|
||||
Assert.Equal("", gd.GetProperty("AvatarItemDesc").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, gd.GetProperty("AvatarItemType").ValueKind);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2815 @@
|
||||
{
|
||||
"AALDCAANEMM": [
|
||||
"CurrencyPurchaseOfferId",
|
||||
"CurrencyId",
|
||||
"Order",
|
||||
"Name",
|
||||
"CurrencyAmount",
|
||||
"Price",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"ABJLKAFOPMP": [
|
||||
"GiftDropIds",
|
||||
"Message",
|
||||
"SubscriptionPurchase"
|
||||
],
|
||||
"ACKNGEEHHAE": [
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"Tags",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions"
|
||||
],
|
||||
"ADAFNGNADFH": [
|
||||
"InviteeAccountId",
|
||||
"CreatedAt",
|
||||
"VerifiedAt"
|
||||
],
|
||||
"ADDGMGGOLHM": [
|
||||
"BlobName",
|
||||
"Title",
|
||||
"Description",
|
||||
"ThumbnailBlobName",
|
||||
"SourceUrl"
|
||||
],
|
||||
"ADMGGPDGKCE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"AECJMONHBHM": [
|
||||
"ClubId",
|
||||
"LastReadAnnouncementId",
|
||||
"Announcements"
|
||||
],
|
||||
"AEGADMDIKNE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"AEGCKMKICFG": [
|
||||
"AccountId",
|
||||
"UserName",
|
||||
"DisplayName",
|
||||
"DisplayEmoji",
|
||||
"ProfileImage",
|
||||
"BannerImage",
|
||||
"TreatAsJunior",
|
||||
"HasBirthday",
|
||||
"PersonalPronouns",
|
||||
"IdentityFlags",
|
||||
"CreatedAt",
|
||||
"IsJunior"
|
||||
],
|
||||
"AEHFGHPLNDO": [
|
||||
"AccountId",
|
||||
"LastVisitedAt"
|
||||
],
|
||||
"AFHCKGCJNFM": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"AFJDLEFKNFP": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"AHJKILCAGML": [
|
||||
"ChallengeId",
|
||||
"Name",
|
||||
"Config",
|
||||
"Description",
|
||||
"Tooltip",
|
||||
"Complete"
|
||||
],
|
||||
"AIEJFLGKIGN": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"AJEDJLBJLFA": [
|
||||
"AccountId",
|
||||
"ClubId",
|
||||
"MembershipType",
|
||||
"CreatedAt"
|
||||
],
|
||||
"AKDFPANIAFL": [
|
||||
"AnnouncementId",
|
||||
"AnnouncementType",
|
||||
"Title",
|
||||
"Body",
|
||||
"ImageName",
|
||||
"LinkType",
|
||||
"LinkName",
|
||||
"LinkButtonLabel",
|
||||
"LinkUri",
|
||||
"Platform",
|
||||
"CreatedAt"
|
||||
],
|
||||
"AKKPACEJJFK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"ALGKCAKCFAJ": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"ALJPHDEAHBK": [
|
||||
"Result",
|
||||
"Tags"
|
||||
],
|
||||
"AMPFGJPKHOD": [
|
||||
"Device_code",
|
||||
"User_code",
|
||||
"Verification_uri",
|
||||
"Verification_uri_complete",
|
||||
"Expires_in",
|
||||
"Interval"
|
||||
],
|
||||
"ANHGLIFGACE": [
|
||||
"RoomKeyId",
|
||||
"ReplicationId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"PurchaseCurrencyId",
|
||||
"CreatedAt",
|
||||
"ImageName"
|
||||
],
|
||||
"ANNOJCNMHIG": [
|
||||
"CustomAvatarItemId",
|
||||
"CreatorAccountId",
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"Accessibility",
|
||||
"IsFeatured",
|
||||
"BaseAvatarItemId",
|
||||
"BaseAvatarItemColor",
|
||||
"DesignFilename",
|
||||
"ThumbnailImageFilename",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"ANPJKOOEEBB": [
|
||||
"Identifier",
|
||||
"Genuine",
|
||||
"GenuineCheckAvailable"
|
||||
],
|
||||
"AOAACMFGAGK": [
|
||||
"ChallengeMapId",
|
||||
"ChallengeThemeString",
|
||||
"Challenges",
|
||||
"Gift",
|
||||
"FallbackGiftName",
|
||||
"StartAt",
|
||||
"EndAt",
|
||||
"ServerTime",
|
||||
"CompletedRequired"
|
||||
],
|
||||
"APOJHADGCNA": [
|
||||
"Ids",
|
||||
"CreatedAts",
|
||||
"ConsumableItemDesc",
|
||||
"Count",
|
||||
"InitialCount",
|
||||
"IsActive",
|
||||
"ActiveDurationMinutes",
|
||||
"IsTransferable"
|
||||
],
|
||||
"BAEBDMPPKKE": [
|
||||
"Take",
|
||||
"ContinuationToken",
|
||||
"InjectedFeedItems",
|
||||
"FeedInstanceId"
|
||||
],
|
||||
"BBACJMOPFPE": [
|
||||
"ProgressionEventRewardId",
|
||||
"GiftDropId",
|
||||
"ImageName",
|
||||
"Xp",
|
||||
"RewardIndex",
|
||||
"IsBonus"
|
||||
],
|
||||
"BBEFAELEANL": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"BBFGGPEMLLE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"BBMJLKMGFGA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"BCEJDGGBBHC": [
|
||||
"Price",
|
||||
"CurrencyId"
|
||||
],
|
||||
"BCMOCNHAJCL": [
|
||||
"Status",
|
||||
"Consumable"
|
||||
],
|
||||
"BGFBJCIOIFP": [
|
||||
"SkuId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"Price",
|
||||
"OculusSkuId",
|
||||
"AppleProductId",
|
||||
"PsnProductLabel",
|
||||
"XboxProductId",
|
||||
"XboxStoreId",
|
||||
"GooglePlaySkuId",
|
||||
"PicoSkuId",
|
||||
"IsSingleUse",
|
||||
"ShouldAppearInTokenStore",
|
||||
"Data"
|
||||
],
|
||||
"BIMHOENEGII": [
|
||||
"Group",
|
||||
"IsCompleted",
|
||||
"ClearedAt"
|
||||
],
|
||||
"BJHOKILBIML": [
|
||||
"FeedItemType",
|
||||
"FeedItemId"
|
||||
],
|
||||
"BKCOBJILFEI": [
|
||||
"KeepsakeFeatureEnabled",
|
||||
"KeepsakeRoomLimit",
|
||||
"SocialXpBoostEnabled"
|
||||
],
|
||||
"BKFOGBGPLFP": [
|
||||
"SavedImageId",
|
||||
"ImageName",
|
||||
"Username",
|
||||
"PlayerId",
|
||||
"RoomName",
|
||||
"RoomId"
|
||||
],
|
||||
"BLADEIPEPOH": [
|
||||
"SavedImageId",
|
||||
"ImageName",
|
||||
"PlayerId",
|
||||
"RoomId",
|
||||
"PlayerEventId",
|
||||
"ClubId",
|
||||
"Description",
|
||||
"Accessibility",
|
||||
"AccessibilityLocked",
|
||||
"SavedImageType",
|
||||
"CreatedAt",
|
||||
"CheerCount",
|
||||
"CommentCount"
|
||||
],
|
||||
"BLIBJIHOENF": [
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"MainImageName",
|
||||
"State",
|
||||
"CreatorAccountId",
|
||||
"Category",
|
||||
"Visibility",
|
||||
"Joinability",
|
||||
"AllowJuniors",
|
||||
"MinLevel",
|
||||
"MemberCount",
|
||||
"IsRRO",
|
||||
"ClubChatEnabled",
|
||||
"ClubhouseRoomId",
|
||||
"ClubType"
|
||||
],
|
||||
"BLPIIBBPOJM": [
|
||||
"Action",
|
||||
"Reason",
|
||||
"Error"
|
||||
],
|
||||
"BLPNLMALDAC": [
|
||||
"AccountId",
|
||||
"Bio"
|
||||
],
|
||||
"BPAPIKPFDAH": [
|
||||
"Difficulty",
|
||||
"EN_US"
|
||||
],
|
||||
"BPHCOIBNCDP": [
|
||||
"ClubId",
|
||||
"Type",
|
||||
"EditDetails",
|
||||
"ApproveMember",
|
||||
"CreateEvent",
|
||||
"PostAnnouncement",
|
||||
"EditPermissionSettings",
|
||||
"BanUnban"
|
||||
],
|
||||
"CAAJPHFCIPK": [
|
||||
"ReportCategory",
|
||||
"Reason"
|
||||
],
|
||||
"CAEHDIMCGLD": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"CAKCKAMAAPP": [
|
||||
"ClubId",
|
||||
"LastAnnouncementId",
|
||||
"LastReadAnnouncementId",
|
||||
"Announcements"
|
||||
],
|
||||
"CBKDKACJDOF": [
|
||||
"ChatMessageId",
|
||||
"ChatThreadId",
|
||||
"SenderPlayerId",
|
||||
"TimeSent",
|
||||
"Contents",
|
||||
"ModerationState"
|
||||
],
|
||||
"CBNEDELBFAJ": [
|
||||
"Clubs",
|
||||
"TotalClubs",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"CCAGOJFGLLO": [
|
||||
"FeedItemType",
|
||||
"FeedItemId",
|
||||
"FeedAlgorithmVersion"
|
||||
],
|
||||
"CCKEEAMMHPJ": [
|
||||
"UnityAssetId",
|
||||
"CreatedByAccountId",
|
||||
"BakedUnityAssets"
|
||||
],
|
||||
"CDEDNEPPPND": [
|
||||
"LevelProgressionMaps",
|
||||
"DailyObjectives",
|
||||
"ServerMaintenance",
|
||||
"AutoMicMutingConfig",
|
||||
"StorefrontConfig",
|
||||
"RoomKeyConfig",
|
||||
"RoomCurrencyConfig",
|
||||
"ShareBaseUrl"
|
||||
],
|
||||
"CEAMINNJFGO": [
|
||||
"Left",
|
||||
"Right",
|
||||
"Spine",
|
||||
"Pelvis",
|
||||
"Root",
|
||||
"Torso",
|
||||
"Body",
|
||||
"Hips",
|
||||
"Neck",
|
||||
"Chest",
|
||||
"Head",
|
||||
"Arm",
|
||||
"Hand",
|
||||
"Wrist",
|
||||
"Elbow",
|
||||
"Palm",
|
||||
"Leg",
|
||||
"Thigh",
|
||||
"Calf",
|
||||
"Femur",
|
||||
"Knee",
|
||||
"Foot",
|
||||
"Ankle",
|
||||
"Hip",
|
||||
"Tail",
|
||||
"Eye",
|
||||
"Dummy",
|
||||
"Collar",
|
||||
"Clavicle",
|
||||
"Finger",
|
||||
"Index",
|
||||
"Mid",
|
||||
"Pinky",
|
||||
"Thumb",
|
||||
"Adjust",
|
||||
"Twist",
|
||||
"Toe",
|
||||
"Lid",
|
||||
"Brow",
|
||||
"Lash"
|
||||
],
|
||||
"CENEMCMGDKG": [
|
||||
"Tags",
|
||||
"PlayerEventId",
|
||||
"CreatorPlayerId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"AttendeeCount",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions",
|
||||
"BroadcastingRoomInstanceId"
|
||||
],
|
||||
"CGACOKHBJCB": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"CGCKIMIEHNM": [
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"CHEMHPGCJBM": [
|
||||
"PlayerId",
|
||||
"Score",
|
||||
"Rank"
|
||||
],
|
||||
"CHKOLCGMCKJ": [
|
||||
"ReportBudget",
|
||||
"FilterType",
|
||||
"SampleRate",
|
||||
"LogLineCount",
|
||||
"CaptureNativeCrashes",
|
||||
"ANRThresholdMs",
|
||||
"MessageCount",
|
||||
"MessageRegex",
|
||||
"VersionRegex"
|
||||
],
|
||||
"CICAKEDCJLA": [
|
||||
"Email",
|
||||
"Phone",
|
||||
"Birthday",
|
||||
"JuniorState",
|
||||
"ParentAccountId",
|
||||
"AvailableUsernameChanges",
|
||||
"AccountId",
|
||||
"UserName",
|
||||
"DisplayName",
|
||||
"DisplayEmoji",
|
||||
"ProfileImage",
|
||||
"BannerImage",
|
||||
"TreatAsJunior",
|
||||
"HasBirthday",
|
||||
"PersonalPronouns",
|
||||
"IdentityFlags",
|
||||
"CreatedAt",
|
||||
"IsJunior"
|
||||
],
|
||||
"CICEHLNDLDE": [
|
||||
"RoomConsumableId",
|
||||
"AccountId",
|
||||
"Count",
|
||||
"ConcurrencyCode",
|
||||
"ModifiedAt",
|
||||
"Consumable"
|
||||
],
|
||||
"CIKAJCJMOFJ": [
|
||||
"MembershipRequestId",
|
||||
"AccountId",
|
||||
"InviterAccountId",
|
||||
"ClubId",
|
||||
"MembershipType",
|
||||
"MembershipStatus",
|
||||
"CreatedAt"
|
||||
],
|
||||
"CJPKAAJBLAJ": [
|
||||
"Id",
|
||||
"Name",
|
||||
"Description",
|
||||
"StartDate",
|
||||
"EndDate",
|
||||
"WasManuallyClosed",
|
||||
"TestCases",
|
||||
"Tags",
|
||||
"NumTestCases",
|
||||
"NumPassedTestCases",
|
||||
"NumFailedTestCases"
|
||||
],
|
||||
"CLHBPPNOLHP": [
|
||||
"Version",
|
||||
"ButtonNumber",
|
||||
"Override",
|
||||
"CustomRoomName",
|
||||
"CustomTitle",
|
||||
"CustomDescription",
|
||||
"DefaultRoomName",
|
||||
"DefaultTitle",
|
||||
"DefaultDescription"
|
||||
],
|
||||
"CLIANCNHNIE": [
|
||||
"AvatarItemType",
|
||||
"AvatarItemDesc",
|
||||
"FriendlyName",
|
||||
"Tooltip",
|
||||
"Rarity",
|
||||
"TagList",
|
||||
"AvatarItemId",
|
||||
"IsBaseAvatarItem"
|
||||
],
|
||||
"CMBHBADOOAF": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"CMHEJKENPBH": [
|
||||
"TriggerType",
|
||||
"TriggerId",
|
||||
"TriggeredAt"
|
||||
],
|
||||
"CMLNDGCIPIH": [
|
||||
"Index",
|
||||
"Group",
|
||||
"Progress",
|
||||
"VisualProgress",
|
||||
"IsCompleted",
|
||||
"HasClaimedReward"
|
||||
],
|
||||
"CNFFPECMOHE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"CNNAFLNKDCL": [
|
||||
"AccountId",
|
||||
"KeepsakeInstanceId",
|
||||
"CollectedAt"
|
||||
],
|
||||
"CNNEAFEIMPP": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"COIELAHKALG": [
|
||||
"Enabled",
|
||||
"Key",
|
||||
"Region"
|
||||
],
|
||||
"DCECPNPOEFG": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DDDLCGDEPNH": [
|
||||
"PlayerEventResponseId",
|
||||
"PlayerEventId",
|
||||
"PlayerId",
|
||||
"CreatedAt",
|
||||
"Type"
|
||||
],
|
||||
"DDFBPFOBIAO": [
|
||||
"ImageName",
|
||||
"Accessibility"
|
||||
],
|
||||
"DDFNNBDLBKF": [
|
||||
"AccountId",
|
||||
"ClubId",
|
||||
"SubscriberCount"
|
||||
],
|
||||
"DEIDIGKKLHE": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"DFKDBJMNKCO": [
|
||||
"Event",
|
||||
"NewSubscription"
|
||||
],
|
||||
"DHAPGKPJDKH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DHMKNFOLCGM": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DICIOFKDIEE": [
|
||||
"Season",
|
||||
"Name",
|
||||
"StartAt",
|
||||
"EndAt",
|
||||
"CurrencyType",
|
||||
"EliteUpgrade",
|
||||
"Tiers",
|
||||
"PersonalDetails",
|
||||
"StorefrontType",
|
||||
"NextUpdate",
|
||||
"NewUntil"
|
||||
],
|
||||
"DIEEOOKELHG": [
|
||||
"AccountId",
|
||||
"IsRecentHistoryVisible"
|
||||
],
|
||||
"DILNAKMDJOF": [
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"KeepsakeCategory"
|
||||
],
|
||||
"DJCLNEEBAJP": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DJFEOAGKKAK": [
|
||||
"FreshnessSeconds",
|
||||
"Url"
|
||||
],
|
||||
"DJFIAOIFLDI": [
|
||||
"Memberships",
|
||||
"TotalMemberships",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"DJHIABKJLBD": [
|
||||
"RoomInstanceId",
|
||||
"PhotonAccessToken",
|
||||
"Permissions"
|
||||
],
|
||||
"DKHBLLDMDHB": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DKMBKPLABAG": [
|
||||
"Type",
|
||||
"Score"
|
||||
],
|
||||
"DKNKIEOODHK": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DLJCLHBGADL": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DNBAAJILMPE": [
|
||||
"RoomId",
|
||||
"ClubId",
|
||||
"PlayerCount"
|
||||
],
|
||||
"DOINHLNFOCP": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DOJEJNECAAI": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DOMOBFDIAJA": [
|
||||
"AccountId",
|
||||
"CheeredAt"
|
||||
],
|
||||
"DPHFOPBAAPN": [
|
||||
"PlayerId",
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"FilterType",
|
||||
"SortAscending"
|
||||
],
|
||||
"DPIECEMDKPL": [
|
||||
"CreatorPlayerId",
|
||||
"Data",
|
||||
"IsValid"
|
||||
],
|
||||
"DPKHIGPMCIP": [
|
||||
"InviteId",
|
||||
"Name"
|
||||
],
|
||||
"DPNLANKOHON": [
|
||||
"AccountId",
|
||||
"CurrencyId",
|
||||
"Balance",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"DatatypeImplementation": [
|
||||
"ENTITIES",
|
||||
"ENTITY",
|
||||
"ID",
|
||||
"IDREF",
|
||||
"IDREFS",
|
||||
"NMTOKEN",
|
||||
"NMTOKENS",
|
||||
"NOTATION"
|
||||
],
|
||||
"EACKMEGHIGM": [
|
||||
"AccountId",
|
||||
"ClubId",
|
||||
"CreatedAt"
|
||||
],
|
||||
"ECEBBLBCFKO": [
|
||||
"AccountId",
|
||||
"BannedByAccountId",
|
||||
"BanStartTime"
|
||||
],
|
||||
"ECHJAKNMEEC": [
|
||||
"KeepsakeRoomListId",
|
||||
"ProgressionEventId",
|
||||
"UnlockItemAvatarItemId",
|
||||
"UnlockItemGiftDropId",
|
||||
"UnlockItemLockDurationTicks",
|
||||
"RoomUnlockStartOffsetTicks",
|
||||
"RoomUnlockIntervalTicks",
|
||||
"RoomUnlockBatchSize",
|
||||
"RoomType",
|
||||
"KeepsakeRooms"
|
||||
],
|
||||
"ECIINMENOGP": [
|
||||
"BalanceAddType",
|
||||
"Delta",
|
||||
"Balance",
|
||||
"Platform",
|
||||
"CurrencyType"
|
||||
],
|
||||
"EDMKBBPGJAB": [
|
||||
"Balance",
|
||||
"RoomKeyResponse"
|
||||
],
|
||||
"EEAAGIJHLOJ": [
|
||||
"PlayerEvent",
|
||||
"Result",
|
||||
"TagModifyResult"
|
||||
],
|
||||
"EHFKGCIDOFL": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"EHKEOCKBCDH": [
|
||||
"Items",
|
||||
"FeedInstanceId",
|
||||
"FeedContext",
|
||||
"FeedAlgorithmVersion"
|
||||
],
|
||||
"EIFIAMHKFAG": [
|
||||
"PurchaseReminderId",
|
||||
"SkuId",
|
||||
"TokenPrice",
|
||||
"Title",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"EndDate",
|
||||
"BonusGiftDrops"
|
||||
],
|
||||
"EJEJHJPCBOE": [
|
||||
"KeepsakeRoomId",
|
||||
"RoomId",
|
||||
"KeepsakeRoomListId",
|
||||
"Type",
|
||||
"Order"
|
||||
],
|
||||
"EJIHDJDKKEC": [
|
||||
"AccountId",
|
||||
"RoomKeyId"
|
||||
],
|
||||
"EJNFJPNOFFO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"EKEFFNBIOHJ": [
|
||||
"ChatThreadId",
|
||||
"LastReadMessageId",
|
||||
"Messages",
|
||||
"LatestMessage",
|
||||
"PlayerIds",
|
||||
"ChatThreadName",
|
||||
"SnoozedUntil",
|
||||
"IsFavorited",
|
||||
"ClubId"
|
||||
],
|
||||
"EKHJKDNHOPB": [
|
||||
"OutfitSelections",
|
||||
"OutfitSelectionsV2",
|
||||
"FaceFeatures",
|
||||
"SkinColor",
|
||||
"HairColor",
|
||||
"CustomAvatarItems"
|
||||
],
|
||||
"EKMLNNNABAD": [
|
||||
"UnityAssetId",
|
||||
"RoomData",
|
||||
"SubRoomData",
|
||||
"InventionUsage",
|
||||
"PersistenceVersion",
|
||||
"Description",
|
||||
"AutoPublish"
|
||||
],
|
||||
"EMBCEDNHFLB": [
|
||||
"GiftDropId",
|
||||
"FriendlyName",
|
||||
"Tooltip",
|
||||
"TagList",
|
||||
"ConsumableItemDesc",
|
||||
"AvatarItemDesc",
|
||||
"AvatarItemType",
|
||||
"EquipmentPrefabName",
|
||||
"EquipmentModificationGuid",
|
||||
"IsQuery",
|
||||
"Unique",
|
||||
"SubscribersOnly",
|
||||
"Rarity",
|
||||
"AvatarItemId",
|
||||
"CurrencyType",
|
||||
"Currency",
|
||||
"Context",
|
||||
"ItemSetId",
|
||||
"ItemSetFriendlyName"
|
||||
],
|
||||
"EMJAJEHCIFN": [
|
||||
"MarketingEmails",
|
||||
"InviteEmails",
|
||||
"GameClientTextInvites",
|
||||
"FriendRequestEmails",
|
||||
"CreatorPublishedNewRoomEmails",
|
||||
"MyRoomSummaryEmails",
|
||||
"NewPhotoTagEmails",
|
||||
"PlayerEventInviteEmails",
|
||||
"PlayerEventReminderEmails"
|
||||
],
|
||||
"ENJDEONHNMO": [
|
||||
"Created",
|
||||
"Responses"
|
||||
],
|
||||
"EOFEKNIHNNC": [
|
||||
"PlayerEventId",
|
||||
"BroadcastRoomInstanceId"
|
||||
],
|
||||
"EOILOIMIJLJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"EOIMIBBJBCB": [
|
||||
"ImageName",
|
||||
"Title",
|
||||
"Subtitle"
|
||||
],
|
||||
"EPPPACFECMH": [
|
||||
"Type",
|
||||
"Reference"
|
||||
],
|
||||
"EncodingTable": [
|
||||
"Big5",
|
||||
"GB2312",
|
||||
"IBM850",
|
||||
"IBM852",
|
||||
"IBM857",
|
||||
"IBM861",
|
||||
"IBM869"
|
||||
],
|
||||
"FAMGNALGHPN": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FBNOJCDDBJK": [
|
||||
"XPAwardStrings",
|
||||
"TotalXPAwarded",
|
||||
"NewProgress"
|
||||
],
|
||||
"FCIBLPCOODP": [
|
||||
"RoomConsumableId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"Price",
|
||||
"PurchaseCurrencyId",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"FCJNIBGKHDM": [
|
||||
"ClubId",
|
||||
"MembershipType"
|
||||
],
|
||||
"FDPFICCCJNM": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FEJOMKJBOOE": [
|
||||
"RoomId",
|
||||
"EarningsDistributionMapping",
|
||||
"EarningsDistributionMethod"
|
||||
],
|
||||
"FIFFMMLKKCJ": [
|
||||
"PlayerId",
|
||||
"Level",
|
||||
"XP"
|
||||
],
|
||||
"FJJIJOGNMCP": [
|
||||
"ReferralRewardTierId",
|
||||
"RequiredReferralCount",
|
||||
"RewardSelectionCount"
|
||||
],
|
||||
"FJNBDGFCLOF": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"FKANCKLCCDI": [
|
||||
"Id",
|
||||
"FromPlayerId",
|
||||
"ConsumableItemDesc",
|
||||
"AvatarItemType",
|
||||
"AvatarItemDesc",
|
||||
"EquipmentPrefabName",
|
||||
"EquipmentModificationGuid",
|
||||
"CurrencyType",
|
||||
"Currency",
|
||||
"Xp",
|
||||
"GiftContext",
|
||||
"GiftRarity",
|
||||
"Message",
|
||||
"Platform",
|
||||
"PlatformsToSpawnOn",
|
||||
"BalanceType"
|
||||
],
|
||||
"FKPPDBLFKHJ": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FLEDNKHKIND": [
|
||||
"InfluencerIds",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"FLELPHJDLNG": [
|
||||
"Filename",
|
||||
"Hash",
|
||||
"OwnershipProof"
|
||||
],
|
||||
"FLMCOHEGJEI": [
|
||||
"RequiresEliteUpgrade",
|
||||
"GiftDrop",
|
||||
"SeasonXpBoost"
|
||||
],
|
||||
"FMIDLABJCLM": [
|
||||
"Strings",
|
||||
"StringPackageVersion"
|
||||
],
|
||||
"FMKALNIKMKF": [
|
||||
"AccountId",
|
||||
"MembershipType"
|
||||
],
|
||||
"FMNDPHMOIIH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FNDBGOGAIFL": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"FNIDAIDJBBO": [
|
||||
"PlayerID",
|
||||
"RelationshipType",
|
||||
"Muted",
|
||||
"Ignored",
|
||||
"Favorited"
|
||||
],
|
||||
"FNNAJDFFICN": [
|
||||
"ReportId",
|
||||
"Client"
|
||||
],
|
||||
"FOIMFMEEGDI": [
|
||||
"SupportsJoinInProgress",
|
||||
"UseLevelBasedMatchmaking",
|
||||
"UseAgeBasedMatchmaking",
|
||||
"UseRecRoyaleMatchmaking",
|
||||
"SubRoomId",
|
||||
"RoomId",
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"UnityAssetId",
|
||||
"UnityAsset",
|
||||
"UnityAssetHash",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"DataSavedAt",
|
||||
"IsSandbox",
|
||||
"MaxPlayers",
|
||||
"Accessibility"
|
||||
],
|
||||
"FOKKDBJEFOO": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"FOOOKJCPBKC": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FPGBBCLENDA": [
|
||||
"StorefrontType",
|
||||
"NextUpdate",
|
||||
"NewUntil"
|
||||
],
|
||||
"FPLGJGDJIJG": [
|
||||
"StorefrontType",
|
||||
"PurchasableItemId",
|
||||
"CurrencyType",
|
||||
"RequestedPrice",
|
||||
"CouponConsumablePlayerMappingId",
|
||||
"Gift"
|
||||
],
|
||||
"FPNMHECOPPF": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"FPNOOOFJBJF": [
|
||||
"RankStart",
|
||||
"RankEnd",
|
||||
"PlayerId",
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"FilterType",
|
||||
"SortAscending"
|
||||
],
|
||||
"FastDualShock4GamepadHID": [
|
||||
"Dpad",
|
||||
"Square",
|
||||
"Cross",
|
||||
"Circle",
|
||||
"Triangle"
|
||||
],
|
||||
"FastKeyboard": [
|
||||
"AnyKey",
|
||||
"Escape",
|
||||
"Space",
|
||||
"Enter",
|
||||
"Tab",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"Shift",
|
||||
"Alt",
|
||||
"Backspace",
|
||||
"Home",
|
||||
"End",
|
||||
"Insert",
|
||||
"Delete",
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12"
|
||||
],
|
||||
"FastMouse": [
|
||||
"Pressure",
|
||||
"Radius",
|
||||
"Position",
|
||||
"Delta",
|
||||
"Scroll",
|
||||
"Press"
|
||||
],
|
||||
"FastTouchscreen": [
|
||||
"Pressure",
|
||||
"Radius",
|
||||
"Position",
|
||||
"Delta",
|
||||
"Press"
|
||||
],
|
||||
"GACOLGJKHBI": [
|
||||
"VersionStatus",
|
||||
"UpdateNotificationStage"
|
||||
],
|
||||
"GAGIJHJGFJD": [
|
||||
"AccountId",
|
||||
"CurrencyId",
|
||||
"Balance",
|
||||
"AmountAwarded",
|
||||
"AwardedAt"
|
||||
],
|
||||
"GAODOHODBDE": [
|
||||
"FailedInvites",
|
||||
"Result"
|
||||
],
|
||||
"GBPDOLJBABB": [
|
||||
"Success",
|
||||
"Message"
|
||||
],
|
||||
"GBPHFPMCGHH": [
|
||||
"Subscription",
|
||||
"PlatformAccountSubscribedPlayerId"
|
||||
],
|
||||
"GCKGIDLDBNH": [
|
||||
"Error",
|
||||
"Error_description",
|
||||
"Access_token",
|
||||
"Refresh_token",
|
||||
"Key"
|
||||
],
|
||||
"GDGCOAMMCIH": [
|
||||
"MicSpamVolumeThreshold",
|
||||
"MicVolumeSampleInterval",
|
||||
"MicVolumeSampleRollingWindowLength",
|
||||
"MicSpamSamplePercentageForWarning",
|
||||
"MicSpamSamplePercentageForWarningToEnd",
|
||||
"MicSpamSamplePercentageForForceMute",
|
||||
"MicSpamSamplePercentageForForceMuteToEnd",
|
||||
"MicSpamWarningStateVolumeMultiplier"
|
||||
],
|
||||
"GEEKOKDMAHG": [
|
||||
"AccountId",
|
||||
"ApiKey"
|
||||
],
|
||||
"GEGADPCDGBO": [
|
||||
"Tag",
|
||||
"Type"
|
||||
],
|
||||
"GEMBOCLPFGE": [
|
||||
"AccountId",
|
||||
"FavoritedAt"
|
||||
],
|
||||
"GFCIGEIEPGH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"GFGBAAADKEL": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"GFGKDCENJMB": [
|
||||
"AccountId",
|
||||
"Xp",
|
||||
"GameMinutesToday",
|
||||
"RewardsCollected",
|
||||
"BonusRewardsCollected",
|
||||
"XpBoostLastPurchasedAt"
|
||||
],
|
||||
"GFHNJMMBHFD": [
|
||||
"PlayerEventId",
|
||||
"Type"
|
||||
],
|
||||
"GFIHPOOFHLC": [
|
||||
"AnnouncementId",
|
||||
"CreatorAccountId",
|
||||
"ClubId",
|
||||
"Title",
|
||||
"Body",
|
||||
"ImageName",
|
||||
"CreatedAt",
|
||||
"Meta"
|
||||
],
|
||||
"GFKKOADFJKC": [
|
||||
"CurrencyId",
|
||||
"RecipientId",
|
||||
"Amount",
|
||||
"TransactionId"
|
||||
],
|
||||
"GFMOBMCIOJI": [
|
||||
"AccountId",
|
||||
"IsCheerful",
|
||||
"SelectedCheer",
|
||||
"CheerCredit",
|
||||
"CheerGeneral",
|
||||
"CheerHelpful",
|
||||
"CheerCreative",
|
||||
"CheerGreatHost",
|
||||
"CheerSportsman"
|
||||
],
|
||||
"GGFHKCDELJH": [
|
||||
"Id",
|
||||
"Link"
|
||||
],
|
||||
"GHBIGGJKPFI": [
|
||||
"Events",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"GIFNHCGDNEM": [
|
||||
"Platform",
|
||||
"PlatformId",
|
||||
"AccountId",
|
||||
"LastLoginTime",
|
||||
"RequirePassword",
|
||||
"RefreshToken"
|
||||
],
|
||||
"GIJCKEDKGLH": [
|
||||
"ReportCategory",
|
||||
"PlayerEventId",
|
||||
"Details"
|
||||
],
|
||||
"GJGLBNABMNL": [
|
||||
"CreatorAccountId",
|
||||
"ClubId",
|
||||
"MembershipType"
|
||||
],
|
||||
"GKFBDCHILOD": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"GLEGPFFPDBE": [
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"SubRooms",
|
||||
"Roles",
|
||||
"Tags",
|
||||
"LoadScreens",
|
||||
"PromoImages",
|
||||
"PromoExternalContent",
|
||||
"RoomId",
|
||||
"IsDorm",
|
||||
"MaxPlayerCalculationMode",
|
||||
"MaxPlayers",
|
||||
"PersistenceVersion",
|
||||
"CloningAllowed",
|
||||
"DisableMicAutoMute",
|
||||
"DisableRoomComments",
|
||||
"EncryptVoiceChat",
|
||||
"ToxmodEnabled",
|
||||
"LoadScreenLocked",
|
||||
"IsDeveloperOwned",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"GLENKGBDMAO": [
|
||||
"Result",
|
||||
"Code",
|
||||
"Url",
|
||||
"RedemptionUrl"
|
||||
],
|
||||
"GMBGBPNMGBA": [
|
||||
"Status",
|
||||
"Invention",
|
||||
"InventionVersion"
|
||||
],
|
||||
"GMDIDJMOEBB": [
|
||||
"Objectives",
|
||||
"ObjectiveGroups"
|
||||
],
|
||||
"GMFJIAMOIEO": [
|
||||
"SubscriptionId",
|
||||
"RecNetPlayerId",
|
||||
"PlatformType",
|
||||
"PlatformId",
|
||||
"PlatformPurchaseId",
|
||||
"Level",
|
||||
"Period",
|
||||
"ExpirationDate",
|
||||
"IsAutoRenewing",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"GMNMHECGEGD": [
|
||||
"CurrencyType",
|
||||
"BalanceAdds"
|
||||
],
|
||||
"GNIPAKCCOOE": [
|
||||
"ProgressionEventId",
|
||||
"Name",
|
||||
"Rewards",
|
||||
"KeepsakeRoomLists",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"CollectionEndTime",
|
||||
"UsesBoost",
|
||||
"BoostDailyGameplayMinutesLimit",
|
||||
"BoostXpMultiplier",
|
||||
"PurchasableXpBoostId",
|
||||
"ActiveExperiment",
|
||||
"ChallengesIconImageName",
|
||||
"RewardsPipImageName",
|
||||
"EventInfoImageName"
|
||||
],
|
||||
"GNMDEJJAHCJ": [
|
||||
"SubscriberDiscountPercent",
|
||||
"StorefrontType",
|
||||
"NextUpdate",
|
||||
"NewUntil",
|
||||
"StoreItems"
|
||||
],
|
||||
"GOHGJADGDJF": [
|
||||
"ReplicationId",
|
||||
"DataBlob",
|
||||
"SubRooms",
|
||||
"Tags",
|
||||
"UnityAssets",
|
||||
"RoomId",
|
||||
"IsDorm",
|
||||
"MaxPlayerCalculationMode",
|
||||
"MaxPlayers",
|
||||
"PersistenceVersion",
|
||||
"CloningAllowed",
|
||||
"DisableMicAutoMute",
|
||||
"DisableRoomComments",
|
||||
"EncryptVoiceChat",
|
||||
"ToxmodEnabled",
|
||||
"LoadScreenLocked",
|
||||
"IsDeveloperOwned",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"GUISkin": [
|
||||
"HorizontalSliderThumbExtent",
|
||||
"VerticalSliderThumbExtent"
|
||||
],
|
||||
"HADOHMCCLEO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HBKAFCAEFCC": [
|
||||
"CreatedAt",
|
||||
"Comment"
|
||||
],
|
||||
"HDLOMAGLILM": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HDOJMEBCAGP": [
|
||||
"Permission",
|
||||
"Role",
|
||||
"Override",
|
||||
"Type",
|
||||
"Value"
|
||||
],
|
||||
"HECILPKCKMK": [
|
||||
"FeaturedRoomGroupId",
|
||||
"Name",
|
||||
"Rooms"
|
||||
],
|
||||
"HEIPMIFEFEE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HEKMOMJFFGJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HFHNOHAODOF": [
|
||||
"ValidTill",
|
||||
"Images"
|
||||
],
|
||||
"HFODJDLIJPG": [
|
||||
"TargetPlayerId",
|
||||
"RoomName",
|
||||
"ActionCode"
|
||||
],
|
||||
"HGJDGKFIOCK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HHJLCKPJOIA": [
|
||||
"PlayerId",
|
||||
"StatusVisibility",
|
||||
"DeviceClass",
|
||||
"VrMovementMode",
|
||||
"RoomInstance",
|
||||
"IsOnline",
|
||||
"AppVersion",
|
||||
"ClientJoinData"
|
||||
],
|
||||
"HHOJLJGGNKN": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HIKOJENMDKE": [
|
||||
"KeepsakeCategoryId",
|
||||
"VisualId",
|
||||
"LimitPerRoom",
|
||||
"XpValue",
|
||||
"IconOutlineImageName",
|
||||
"IconFilledImageName"
|
||||
],
|
||||
"HIOJNFKGAOK": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HIOPBEPNFOO": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HJKLIIPPPEM": [
|
||||
"PlayerEvent",
|
||||
"PlayerEventResponse"
|
||||
],
|
||||
"HJOIJBMAIPO": [
|
||||
"PlaylistId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"HJOKFJKKEJM": [
|
||||
"BalanceUpdateResult",
|
||||
"TokenBalanceResponse",
|
||||
"OperationResult"
|
||||
],
|
||||
"HJPEABLEEKL": [
|
||||
"RoomConsumableId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"PriceAndCurrency"
|
||||
],
|
||||
"HKAGNIGNBOJ": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HKIJINJGBGD": [
|
||||
"Type",
|
||||
"User_id",
|
||||
"Identifiers",
|
||||
"Ip",
|
||||
"Gameplay_session_id",
|
||||
"Transaction_id"
|
||||
],
|
||||
"HKPKGOPHLHE": [
|
||||
"RoomInstanceCount",
|
||||
"PlayerCount"
|
||||
],
|
||||
"HLDLKOPNDPF": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HMDONHHKKGA": [
|
||||
"PinnedFilters",
|
||||
"PopularFilters",
|
||||
"TrendingFilters"
|
||||
],
|
||||
"HMGHGBMGNJG": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HMKMGPNMHBM": [
|
||||
"Instances",
|
||||
"CollectionRecords",
|
||||
"KeepsakeProgressionEventIds"
|
||||
],
|
||||
"HMMFNEEAFGP": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"HNLGHDMNMDB": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"HOGIMIPDDGP": [
|
||||
"ImageName",
|
||||
"ImageUrl"
|
||||
],
|
||||
"HPAKKJFOFDF": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HPEGLGGMNMC": [
|
||||
"ChatThread",
|
||||
"ChatResult"
|
||||
],
|
||||
"HPMMKFGDAEC": [
|
||||
"PlayerEventId",
|
||||
"CreatorPlayerId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"AttendeeCount",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions",
|
||||
"BroadcastingRoomInstanceId"
|
||||
],
|
||||
"IACBFJEMJMJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"IAPMDDFENDD": [
|
||||
"PurchasableItemId",
|
||||
"CouponConsumablePlayerMappingId",
|
||||
"Count",
|
||||
"CreatorPlayerId",
|
||||
"RequestedPrice"
|
||||
],
|
||||
"IBAPHBCOBHL": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"IBILPLGNAJE": [
|
||||
"Id",
|
||||
"ImageName",
|
||||
"PlayerId",
|
||||
"RoomId",
|
||||
"PlayerEventId",
|
||||
"Accessibility",
|
||||
"AccessibilityLocked",
|
||||
"Type",
|
||||
"CreatedAt",
|
||||
"TaggedPlayerIds",
|
||||
"CheerCount",
|
||||
"CommentCount"
|
||||
],
|
||||
"ICMBGNCJMJG": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"ICNLODNGFJJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"IDBFKCFEAHM": [
|
||||
"BalanceUpdateResult",
|
||||
"CurrencyBalanceResponse",
|
||||
"OperationResult"
|
||||
],
|
||||
"IDBLMMAGACO": [
|
||||
"Key",
|
||||
"Value"
|
||||
],
|
||||
"IDFEIMCEMCC": [
|
||||
"Level",
|
||||
"Period",
|
||||
"IsAutoRenewing"
|
||||
],
|
||||
"IEIKHFIPDMD": [
|
||||
"CurrencyType",
|
||||
"Price",
|
||||
"StorefrontSaleData"
|
||||
],
|
||||
"IFFEHJEPKOH": [
|
||||
"ReferralsVerifiedCount",
|
||||
"PlayerReferralRewards"
|
||||
],
|
||||
"IFLDEEGLKFG": [
|
||||
"Level",
|
||||
"RequiredXp",
|
||||
"GiftDropId"
|
||||
],
|
||||
"IGMCJEFBJCF": [
|
||||
"ServerTime",
|
||||
"PlayerId",
|
||||
"StatusVisibility",
|
||||
"DeviceClass",
|
||||
"VrMovementMode",
|
||||
"RoomInstance",
|
||||
"IsOnline",
|
||||
"AppVersion",
|
||||
"ClientJoinData"
|
||||
],
|
||||
"IGNECOJDECH": [
|
||||
"Info",
|
||||
"ReferrerId"
|
||||
],
|
||||
"IHGDKHPLIGH": [
|
||||
"RoomId",
|
||||
"RoomName",
|
||||
"ImageName"
|
||||
],
|
||||
"IJFKOMJBJMI": [
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"StatValue",
|
||||
"CurrentStatValue"
|
||||
],
|
||||
"ILAHMLLJDLE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"ILEHDPADJJF": [
|
||||
"Nouns",
|
||||
"Adjectives"
|
||||
],
|
||||
"ILJOBJDPBNJ": [
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"BaseAvatarItemId",
|
||||
"BaseAvatarItemColor",
|
||||
"Accessibility"
|
||||
],
|
||||
"IMMOJHBENFI": [
|
||||
"Slot",
|
||||
"PreviewImageName",
|
||||
"OutfitSelections",
|
||||
"OutfitSelectionsV2",
|
||||
"FaceFeatures",
|
||||
"SkinColor",
|
||||
"HairColor",
|
||||
"CustomAvatarItems"
|
||||
],
|
||||
"INLPIKBODME": [
|
||||
"CreatorAccountId",
|
||||
"BaseAvatarItemId",
|
||||
"DesignFilename",
|
||||
"BaseAvatarItemColor"
|
||||
],
|
||||
"IOEPPCKGBFL": [
|
||||
"InventionId",
|
||||
"ReplicationId",
|
||||
"CreatorPlayerId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"CurrentVersionNumber",
|
||||
"Accessibility",
|
||||
"ModifiedAt",
|
||||
"CreatedAt",
|
||||
"FirstPublishedAt",
|
||||
"CreationRoomId",
|
||||
"NumPlayersHaveUsedInRoom",
|
||||
"NumDownloads",
|
||||
"CheerCount",
|
||||
"CreatorPermission",
|
||||
"GeneralPermission",
|
||||
"IsAGInvention",
|
||||
"IsCertifiedInvention",
|
||||
"AllowTrial",
|
||||
"Price",
|
||||
"HideFromPlayer"
|
||||
],
|
||||
"IPMGNJMIONC": [
|
||||
"Revenue_currency",
|
||||
"Revenue_amount",
|
||||
"Revenue_type",
|
||||
"Type",
|
||||
"User_id",
|
||||
"Identifiers",
|
||||
"Ip",
|
||||
"Gameplay_session_id",
|
||||
"Transaction_id"
|
||||
],
|
||||
"JAFDEPHGOAF": [
|
||||
"Title",
|
||||
"Message",
|
||||
"ImageName",
|
||||
"ActiveExperiments",
|
||||
"Context",
|
||||
"InputType",
|
||||
"Visibility",
|
||||
"AllowCycling",
|
||||
"PlatformMask",
|
||||
"RestrictToNewUsers",
|
||||
"RoomNames"
|
||||
],
|
||||
"JAHJGFHFKIB": [
|
||||
"Club",
|
||||
"CustomTags",
|
||||
"AdditionalImages",
|
||||
"CoownerPermissions",
|
||||
"ModeratorPermissions",
|
||||
"MemberPermissions",
|
||||
"MyMembershipType"
|
||||
],
|
||||
"JBGKGCLPCHP": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"JBIEJIKHBMG": [
|
||||
"SeedRoom",
|
||||
"Rooms"
|
||||
],
|
||||
"JCPFCOEEELK": [
|
||||
"AccountId",
|
||||
"CurrencyId",
|
||||
"Success",
|
||||
"Error",
|
||||
"Response"
|
||||
],
|
||||
"JDEKAFDPGBE": [
|
||||
"ReplicationId",
|
||||
"PersistenceVersion",
|
||||
"SupportsJoinInProgress",
|
||||
"UseLevelBasedMatchmaking",
|
||||
"UseAgeBasedMatchmaking",
|
||||
"UseRecRoyaleMatchmaking",
|
||||
"SubRoomId",
|
||||
"RoomId",
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"UnityAssetId",
|
||||
"UnityAsset",
|
||||
"UnityAssetHash",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"DataSavedAt",
|
||||
"IsSandbox",
|
||||
"MaxPlayers",
|
||||
"Accessibility"
|
||||
],
|
||||
"JEBCHNDADKK": [
|
||||
"ConcurrencyCodes",
|
||||
"ExpectedPriceAndCurrency"
|
||||
],
|
||||
"JFEKPNOKFBP": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JGJFCFAFGCM": [
|
||||
"TotalXP",
|
||||
"Level",
|
||||
"RankIdx",
|
||||
"RankName",
|
||||
"CurrentLevelXPThreshold",
|
||||
"NextLevelXPThreshold",
|
||||
"NextLevelAcornReward"
|
||||
],
|
||||
"JHNAJMDHFKH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JICIOMDBPDO": [
|
||||
"Id",
|
||||
"ConsumableItemDesc",
|
||||
"Count",
|
||||
"InitialCount",
|
||||
"CreatedAt",
|
||||
"ActiveDurationMinutes",
|
||||
"IsActive",
|
||||
"IsTransferable"
|
||||
],
|
||||
"JIOEIEENGNO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JJEKBGHDCEG": [
|
||||
"Tier",
|
||||
"Rewards",
|
||||
"PurchasableItemId",
|
||||
"Type",
|
||||
"Prices",
|
||||
"SubscriberPrices",
|
||||
"IsFeatured",
|
||||
"AvailableAt",
|
||||
"AvailableUntil",
|
||||
"NewUntil"
|
||||
],
|
||||
"JJGEJHCFEJH": [
|
||||
"GiftDropId",
|
||||
"ConsumableItemDesc",
|
||||
"AvatarItemDesc",
|
||||
"AvatarItemType",
|
||||
"EquipmentPrefabName",
|
||||
"EquipmentModificationGuid",
|
||||
"StorefrontType",
|
||||
"Xp",
|
||||
"Level",
|
||||
"GiftContext",
|
||||
"GiftRarity"
|
||||
],
|
||||
"JKJHOAKNOCF": [
|
||||
"CurrentConcurrencyCode",
|
||||
"NewConcurrencyCode"
|
||||
],
|
||||
"JLIBBFMMLFF": [
|
||||
"CurrencyType",
|
||||
"BalanceAddType",
|
||||
"BaseAward",
|
||||
"BonusAwardMin",
|
||||
"BonusAwardMax",
|
||||
"RateLimitType",
|
||||
"IgnorePartialMultiplier",
|
||||
"MaxPartialMultiplier",
|
||||
"RateLimit",
|
||||
"BalanceInGiftBox"
|
||||
],
|
||||
"JLNFEFHNFBP": [
|
||||
"Title",
|
||||
"Description"
|
||||
],
|
||||
"JLNKMODEKKM": [
|
||||
"BaseAvatarItemId",
|
||||
"BaseAvatarItemColor"
|
||||
],
|
||||
"JMJLMOJCBCK": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"JNFDPPCIPHG": [
|
||||
"CategoryId",
|
||||
"Importance",
|
||||
"Name",
|
||||
"Description",
|
||||
"IsMuteable"
|
||||
],
|
||||
"JOMMALIPDGI": [
|
||||
"RoomId",
|
||||
"IsDorm",
|
||||
"MaxPlayerCalculationMode",
|
||||
"MaxPlayers",
|
||||
"PersistenceVersion",
|
||||
"CloningAllowed",
|
||||
"DisableMicAutoMute",
|
||||
"DisableRoomComments",
|
||||
"EncryptVoiceChat",
|
||||
"ToxmodEnabled",
|
||||
"LoadScreenLocked",
|
||||
"IsDeveloperOwned",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"JPKBLKGJNCA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JPLIIMKJAEL": [
|
||||
"AccountId",
|
||||
"DisallowInAppPurchases"
|
||||
],
|
||||
"JPNMBICAMEE": [
|
||||
"RoomInstanceId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"IsFull",
|
||||
"CreatedAt",
|
||||
"PlayerIds"
|
||||
],
|
||||
"JsonParser_NestedType_JsonValue": [
|
||||
"False",
|
||||
"True"
|
||||
],
|
||||
"KAONMCEONPF": [
|
||||
"Name",
|
||||
"Price",
|
||||
"ProductId"
|
||||
],
|
||||
"KBHFIPFPCMN": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KBLOCDGCGFA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KBMAAKAIPLE": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"KCMDEFMLDJF": [
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"BalanceType",
|
||||
"Data"
|
||||
],
|
||||
"KDAHIOHGPBN": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"KDJILJAIDFG": [
|
||||
"RoomId",
|
||||
"Count"
|
||||
],
|
||||
"KEOLHAONMIF": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"KFAOOJLFCGC": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KFPHOKPEMOF": [
|
||||
"ItemType",
|
||||
"ItemId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"RoomId",
|
||||
"Price",
|
||||
"PurchaseCurrencyId",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"KGNFNBHMAFL": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"KHBGNAMFAHK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KHFKIJEGPDG": [
|
||||
"AccountId",
|
||||
"RoomKeyId",
|
||||
"DoesPlayerOwnRoomKey"
|
||||
],
|
||||
"KIANAKFJGCP": [
|
||||
"FeaturedPlayer",
|
||||
"FeaturedRoomGroup",
|
||||
"CurrentAnnouncement",
|
||||
"InstagramImages",
|
||||
"Videos"
|
||||
],
|
||||
"KILGEFOENFN": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"KJEIGGBIIHP": [
|
||||
"SavedImageId",
|
||||
"IsCheered"
|
||||
],
|
||||
"KKOHONLGIHL": [
|
||||
"Id",
|
||||
"FromPlayerId",
|
||||
"SentTime",
|
||||
"Type",
|
||||
"Data",
|
||||
"RoomId",
|
||||
"PlayerEventId"
|
||||
],
|
||||
"KLHCKBPJKPF_NestedType_DHKPMMHMIJF_NestedType_KKNKAJBPNPC": [
|
||||
"SerializeUnsafe",
|
||||
"ToJsonString"
|
||||
],
|
||||
"KMBHAKAHNGH": [
|
||||
"Room",
|
||||
"SubRoomDataSave"
|
||||
],
|
||||
"KMHNOGNHDLO": [
|
||||
"SubscriptionSeasonMilestoneId",
|
||||
"SeasonId",
|
||||
"StartAt",
|
||||
"GiftDrops"
|
||||
],
|
||||
"KNGMIABFJHK": [
|
||||
"CurrencyBalanceResponse",
|
||||
"TokenBalanceResponse"
|
||||
],
|
||||
"KOEEACLMAFE": [
|
||||
"Referrals",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"KPEIHOCIGGE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KPMMGJELBPP": [
|
||||
"WindowSize",
|
||||
"PlayerId",
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"FilterType",
|
||||
"SortAscending"
|
||||
],
|
||||
"KPNBKKKCOCA": [
|
||||
"UnlockedAt",
|
||||
"ClaimedAt",
|
||||
"Status",
|
||||
"ReferralRewardTier",
|
||||
"IsCurrentTier"
|
||||
],
|
||||
"KPPINBHEKHE": [
|
||||
"Products",
|
||||
"Identifier",
|
||||
"Genuine",
|
||||
"GenuineCheckAvailable"
|
||||
],
|
||||
"LAIJIAIHGCO": [
|
||||
"ReportCategory",
|
||||
"Duration",
|
||||
"GameSessionId",
|
||||
"IsHostKick",
|
||||
"Message",
|
||||
"PlayerIdReporter",
|
||||
"IsBan",
|
||||
"IsVoiceModAutoban",
|
||||
"IsWarning",
|
||||
"VoteKickReason",
|
||||
"TimeoutStartedAt"
|
||||
],
|
||||
"LBOGFNBPIKJ": [
|
||||
"SubRoomId",
|
||||
"RoomId",
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"UnityAssetId",
|
||||
"UnityAsset",
|
||||
"UnityAssetHash",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"DataSavedAt",
|
||||
"IsSandbox",
|
||||
"MaxPlayers",
|
||||
"Accessibility"
|
||||
],
|
||||
"LDGHHCCJAMC": [
|
||||
"Status",
|
||||
"RoomKey"
|
||||
],
|
||||
"LEEAJGDIOHI": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LEINHCHGFON": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"LEMIBENHABD": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LIJHBNDJKNP": [
|
||||
"RewardSelectionId",
|
||||
"RewardType",
|
||||
"Message",
|
||||
"GiftContext",
|
||||
"GiftDrop1",
|
||||
"GiftDrop2",
|
||||
"GiftDrop3",
|
||||
"Subscriber_GiftDrop3",
|
||||
"CreatedAt"
|
||||
],
|
||||
"LIODMDILCAJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LKBGCOELLIN": [
|
||||
"CurrencyId",
|
||||
"PurchaseOffers"
|
||||
],
|
||||
"LKJLPNENINM": [
|
||||
"RoomKeyResponse",
|
||||
"BalanceUpdateResponse"
|
||||
],
|
||||
"LKJPKMBEPCG": [
|
||||
"PrefabName",
|
||||
"ModificationGuid",
|
||||
"FriendlyName",
|
||||
"Tooltip",
|
||||
"Rarity",
|
||||
"Favorited"
|
||||
],
|
||||
"LKLKHEEHEBC": [
|
||||
"UnityAssetId",
|
||||
"Target",
|
||||
"Version",
|
||||
"Filename",
|
||||
"Hash"
|
||||
],
|
||||
"LKMEPPOFHLK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"LMCCNLLHLCJ": [
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LMEECKCCPHG": [
|
||||
"AccountId",
|
||||
"Role",
|
||||
"LastChangedByAccountId",
|
||||
"InvitedRole"
|
||||
],
|
||||
"LMHKHIJIDEO": [
|
||||
"SavedImageId",
|
||||
"Cheer"
|
||||
],
|
||||
"LNMAMOCCBAK": [
|
||||
"Filename",
|
||||
"UnityAssetId",
|
||||
"CreatedByAccountId",
|
||||
"BakedUnityAssets"
|
||||
],
|
||||
"LOEHGGMCKAM": [
|
||||
"ChatResult",
|
||||
"SnoozedUntil"
|
||||
],
|
||||
"LOOLOADLJPG": [
|
||||
"SubscriptionSeasonId",
|
||||
"Name",
|
||||
"ImageName",
|
||||
"StartAt",
|
||||
"NextSeasonStart",
|
||||
"Milestones"
|
||||
],
|
||||
"LPAEOGFPGBD": [
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"MaxPlayers",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile"
|
||||
],
|
||||
"LPFAFIGAIOE": [
|
||||
"Status",
|
||||
"InventoryItem"
|
||||
],
|
||||
"MACBLLENLFD": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"MADLMAFINGF": [
|
||||
"KeepsakeInstanceId",
|
||||
"KeepsakeCategoryConfigId",
|
||||
"PlacedByAccountId",
|
||||
"RoomId",
|
||||
"SubRoomId"
|
||||
],
|
||||
"MAHHAAEGEEP": [
|
||||
"InventionResponse",
|
||||
"BalanceUpdateResponse"
|
||||
],
|
||||
"MBFADJPOGDI": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MCDADMNCDEI": [
|
||||
"WishlistItemId",
|
||||
"AccountId",
|
||||
"PurchasableItemId",
|
||||
"CreatedAt"
|
||||
],
|
||||
"MCMPJPNBJBI": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MEBAFOGDHBB": [
|
||||
"PurchaseCampaignId",
|
||||
"PurchaseReminder",
|
||||
"Name",
|
||||
"TriggerFlags",
|
||||
"LastShown",
|
||||
"ShowCount",
|
||||
"DaysBetweenPopups",
|
||||
"MaxPopups"
|
||||
],
|
||||
"MEEAEDNAPGI": [
|
||||
"CommentId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"AccountId",
|
||||
"CreatedAt",
|
||||
"Message",
|
||||
"Style",
|
||||
"Unread",
|
||||
"PositionX",
|
||||
"PositionY",
|
||||
"PositionZ"
|
||||
],
|
||||
"MFNBIPCHKHC": [
|
||||
"MembershipRequests",
|
||||
"TotalMembershipRequests",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"MGOLBEMMNDA": [
|
||||
"BalanceAddType",
|
||||
"BaseAward",
|
||||
"BonusAward",
|
||||
"RateLimit",
|
||||
"CurrentCount",
|
||||
"Total",
|
||||
"Platform",
|
||||
"BalanceInGiftBox"
|
||||
],
|
||||
"MIFGPMGEMEK": [
|
||||
"RefreshesAt",
|
||||
"RefreshIntervalMinutes",
|
||||
"Room"
|
||||
],
|
||||
"MIHDILLIAFE": [
|
||||
"Instances",
|
||||
"CollectionRecords"
|
||||
],
|
||||
"MIHJPKMLADF": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MIHKAJFMIPE": [
|
||||
"RoomId",
|
||||
"CurrencyId"
|
||||
],
|
||||
"MJGHCJDOJLH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"MJJDCIAIDKI": [
|
||||
"HasEliteUpgrade",
|
||||
"HasEliteUpgradePlatformMask",
|
||||
"CurrentSeasonTier",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"MJKDDBKGNOP": [
|
||||
"ContactDetail",
|
||||
"AccountId",
|
||||
"UserName",
|
||||
"DisplayName",
|
||||
"DisplayEmoji",
|
||||
"ProfileImage",
|
||||
"BannerImage",
|
||||
"TreatAsJunior",
|
||||
"HasBirthday",
|
||||
"PersonalPronouns",
|
||||
"IdentityFlags",
|
||||
"CreatedAt",
|
||||
"IsJunior"
|
||||
],
|
||||
"MJNABIBJDMO": [
|
||||
"Order",
|
||||
"Objective",
|
||||
"Count"
|
||||
],
|
||||
"MKAIABAJIAJ": [
|
||||
"CustomAvatarItemId",
|
||||
"BodyPart"
|
||||
],
|
||||
"MKBLHNGJOBA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MLHBPDKBABN": [
|
||||
"CheerCount",
|
||||
"FavoriteCount",
|
||||
"VisitorCount",
|
||||
"VisitCount"
|
||||
],
|
||||
"MMDMDIJLIEH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MMFCMCPCAJP": [
|
||||
"ChatMessage",
|
||||
"ChatResult"
|
||||
],
|
||||
"MMPLMIAEBLE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MOJLGDKFECO": [
|
||||
"InvitedPlayerId",
|
||||
"Result"
|
||||
],
|
||||
"MOLNFPGNEGP": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"MOMDMDMGEOA": [
|
||||
"ItemType",
|
||||
"ItemId"
|
||||
],
|
||||
"Matchmaking": [
|
||||
"PlayerId",
|
||||
"RoomInstanceId"
|
||||
],
|
||||
"NAGNGCMPGOP": [
|
||||
"RoomId",
|
||||
"KeepsakeInstanceIds"
|
||||
],
|
||||
"NAPOKFHELLC": [
|
||||
"Multiplier",
|
||||
"BalanceAddType"
|
||||
],
|
||||
"NBGPICODFEE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"NCBBKAMCPBC": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"NCHBANKEJMP": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"NDIIFIGNAAM": [
|
||||
"Error",
|
||||
"Identifier",
|
||||
"Genuine",
|
||||
"GenuineCheckAvailable"
|
||||
],
|
||||
"NEJOPOKDPKE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"NEKEOJFNCCJ": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"NFBJOAGJHME": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"NGCPIKDINEI": [
|
||||
"Provider",
|
||||
"Data"
|
||||
],
|
||||
"NHGOFGABJIN": [
|
||||
"RoomInstanceId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"Location",
|
||||
"EventId",
|
||||
"ClubId",
|
||||
"RoomCode",
|
||||
"PhotonRegionId",
|
||||
"PhotonRoomId",
|
||||
"Name",
|
||||
"MaxCapacity",
|
||||
"IsFull",
|
||||
"IsPrivate",
|
||||
"IsInProgress",
|
||||
"EncryptVoiceChat",
|
||||
"RoomInstanceType"
|
||||
],
|
||||
"NIJPNDPJICF": [
|
||||
"Id",
|
||||
"Key",
|
||||
"Title",
|
||||
"Description",
|
||||
"RoomName",
|
||||
"Status",
|
||||
"AssignedPlayerNames",
|
||||
"Tags",
|
||||
"JiraUrl",
|
||||
"Comments"
|
||||
],
|
||||
"NKMEHOHCKOH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"NLIAAGLJBNN": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"NMGHOIMEJIK": [
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"Accessibility"
|
||||
],
|
||||
"NNAGOPNGJFE": [
|
||||
"AmplitudeKey",
|
||||
"RudderStackKey",
|
||||
"UseRudderStack",
|
||||
"StatSigKey"
|
||||
],
|
||||
"OANOOFEPJCI": [
|
||||
"Rooms",
|
||||
"Tags",
|
||||
"PlaylistId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"OBACBEGPKAK": [
|
||||
"ReportCategory",
|
||||
"Details",
|
||||
"ReportedPlayerId"
|
||||
],
|
||||
"OBGIGMCOMDE": [
|
||||
"AdCarouselItemId",
|
||||
"ImageName",
|
||||
"Title",
|
||||
"Description",
|
||||
"PurchasableItemIds",
|
||||
"PurchaseReminderId"
|
||||
],
|
||||
"OBIBPCLDPGA": [
|
||||
"Message",
|
||||
"MoreInfoUrl"
|
||||
],
|
||||
"OCFNOBAONAP": [
|
||||
"Cheered",
|
||||
"Favorited",
|
||||
"LastVisitedAt"
|
||||
],
|
||||
"OCGICPIMNEB": [
|
||||
"ErrorCode",
|
||||
"RoomInstance"
|
||||
],
|
||||
"ODNKLIIJIOH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"OEHMNIAOAOH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"OFGIPBHIAGF": [
|
||||
"ProgressionEventPurchasableXpBoostId",
|
||||
"Cost",
|
||||
"XpMultiplier",
|
||||
"XpCap",
|
||||
"LookbackDurationTicks",
|
||||
"CooldownDurationTicks"
|
||||
],
|
||||
"OFMNNCMPEPA": [
|
||||
"SessionId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"UnityAssetId",
|
||||
"RoomData",
|
||||
"SubRoomData",
|
||||
"SavedByAccountId"
|
||||
],
|
||||
"OGCMIDEFOEE": [
|
||||
"ChatResult",
|
||||
"IsFavorited"
|
||||
],
|
||||
"OGEDLCFDDIO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"OHNEMFHCBIJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"OKHGCKDIILD": [
|
||||
"ImageName",
|
||||
"Index"
|
||||
],
|
||||
"OLACHOEDKFB": [
|
||||
"ReportCategory",
|
||||
"YesVotes",
|
||||
"NoVotes",
|
||||
"PlayerIdReported"
|
||||
],
|
||||
"OLIHBGPDPHF": [
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"BalanceType",
|
||||
"Data"
|
||||
],
|
||||
"OLKCALBDDOI": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"OLPHKLCPFEF": [
|
||||
"InventionId",
|
||||
"ReplicationId",
|
||||
"VersionNumber",
|
||||
"InstantiationCost",
|
||||
"LightsCost",
|
||||
"ChipsCost",
|
||||
"CloudVariablesCost",
|
||||
"BlobName",
|
||||
"BlobHash"
|
||||
],
|
||||
"OMAPLLOIDNA": [
|
||||
"ToPlayerIds",
|
||||
"Message",
|
||||
"GiftContext"
|
||||
],
|
||||
"ONGJBLFBHGC": [
|
||||
"PurchasableItemId",
|
||||
"Type",
|
||||
"Prices",
|
||||
"SubscriberPrices",
|
||||
"IsFeatured",
|
||||
"AvailableAt",
|
||||
"AvailableUntil",
|
||||
"NewUntil"
|
||||
],
|
||||
"OOGKPMKKJBE": [
|
||||
"SalePercent",
|
||||
"SaleStartDate",
|
||||
"SaleEndDate"
|
||||
],
|
||||
"PAKGKIKFGAE": [
|
||||
"Id",
|
||||
"TitleOverride",
|
||||
"UrlOverride"
|
||||
],
|
||||
"PCBAACNNDBH": [
|
||||
"FriendlyImageName",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime"
|
||||
],
|
||||
"PCMLHLIBLNJ": [
|
||||
"Tag",
|
||||
"Type"
|
||||
],
|
||||
"PCODFCJECKL": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"PCPOMPIAFMA": [
|
||||
"Key",
|
||||
"Value",
|
||||
"StartTime",
|
||||
"EndTime"
|
||||
],
|
||||
"PDGENCNGCJI": [
|
||||
"RoomId",
|
||||
"Ids"
|
||||
],
|
||||
"PDIFGFKAMBG": [
|
||||
"PlayerCount",
|
||||
"IsFull",
|
||||
"PlayerEventId",
|
||||
"CreatorPlayerId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"AttendeeCount",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions",
|
||||
"BroadcastingRoomInstanceId"
|
||||
],
|
||||
"PECGEJAAMHB": [
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"PELEHJLMKJO": [
|
||||
"SubRoomDataSaveId",
|
||||
"SubRoomId",
|
||||
"UnityAssetId",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"SavedByAccountId",
|
||||
"SavedOnPlatform",
|
||||
"SavedOnDeviceClass",
|
||||
"Description",
|
||||
"CreatedAt"
|
||||
],
|
||||
"PEOAOMGOMGC": [
|
||||
"CurrencyId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"CurrencyType",
|
||||
"Limit",
|
||||
"ImageName",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"PGACEMBEGJF": [
|
||||
"ToPlayerId",
|
||||
"Message",
|
||||
"Anonymous",
|
||||
"GiftContext"
|
||||
],
|
||||
"PJPBJKLDMNA": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"PKCMBJFBHBO": [
|
||||
"TotalXp",
|
||||
"SocialBoostXp"
|
||||
],
|
||||
"PLLNLPADHCN": [
|
||||
"Resolution",
|
||||
"Language",
|
||||
"Timezone",
|
||||
"Platform"
|
||||
],
|
||||
"POGBAGAHGIA": [
|
||||
"GiftDrop",
|
||||
"PurchasableItemId",
|
||||
"Type",
|
||||
"Prices",
|
||||
"SubscriberPrices",
|
||||
"IsFeatured",
|
||||
"AvailableAt",
|
||||
"AvailableUntil",
|
||||
"NewUntil"
|
||||
],
|
||||
"PPHLKNGCGOE": [
|
||||
"SessionId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"UnityAssetId",
|
||||
"RoomDataFilename",
|
||||
"RoomDataHash",
|
||||
"SubRoomDataFilename",
|
||||
"SubRoomDataHash"
|
||||
],
|
||||
"PlayerInput": [
|
||||
"OnPlayerLeft",
|
||||
"OnDeviceLost",
|
||||
"OnDeviceRegained",
|
||||
"OnControlsChanged"
|
||||
],
|
||||
"PlayerInputManager": [
|
||||
"OnPlayerJoined",
|
||||
"OnPlayerLeft"
|
||||
],
|
||||
"StringHelpers": [
|
||||
"Mouse",
|
||||
"Mice",
|
||||
"Axis",
|
||||
"Axes"
|
||||
],
|
||||
"X509Name": [
|
||||
"C",
|
||||
"O",
|
||||
"T",
|
||||
"OU",
|
||||
"CN",
|
||||
"L",
|
||||
"ST",
|
||||
"SERIALNUMBER",
|
||||
"E",
|
||||
"DC",
|
||||
"UID",
|
||||
"STREET",
|
||||
"SURNAME",
|
||||
"GIVENNAME",
|
||||
"INITIALS",
|
||||
"GENERATION",
|
||||
"UniqueIdentifier",
|
||||
"DN",
|
||||
"Pseudonym",
|
||||
"PostalAddress",
|
||||
"CountryOfCitizenship",
|
||||
"CountryOfResidence",
|
||||
"Gender",
|
||||
"PlaceOfBirth",
|
||||
"DateOfBirth",
|
||||
"PostalCode",
|
||||
"BusinessCategory",
|
||||
"TelephoneNumber"
|
||||
],
|
||||
"XSDSchema": [
|
||||
"True",
|
||||
"False",
|
||||
"Name"
|
||||
],
|
||||
"XmlTreeGen": [
|
||||
"Namespace",
|
||||
"Unique",
|
||||
"Attribute"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
# Every HTTP request the Rec Room 2023-03-21 client can issue.
|
||||
# Recovered from the client binary; see docs/recroom-2023-client-api-complete.md.
|
||||
# verb<TAB>route<TAB>subsystem
|
||||
GET account/bulk identity-account
|
||||
POST account/bulk identity-account
|
||||
GET account/isactivecreator/me identity-account
|
||||
DELETE account/me identity-account
|
||||
GET account/me identity-account
|
||||
POST account/me/changepassword identity-account
|
||||
POST account/me/createlogintoken identity-account
|
||||
GET account/me/haspassword identity-account
|
||||
POST account/me/remoteauth identity-account
|
||||
POST account/recoverpassword identity-account
|
||||
GET account/search identity-account
|
||||
GET account/{0}/bio identity-account
|
||||
GET account/{0}/clubs identity-account
|
||||
PUT accountprivacysettings/recenthistoryvisibility identity-account
|
||||
GET accountprivacysettings/{0} identity-account
|
||||
GET accounts/{0}/receives/{1} identity-account
|
||||
POST actionlink config-telemetry
|
||||
GET actionlink/{code} config-telemetry
|
||||
POST actionlink/{code}/consume config-telemetry
|
||||
GET announcements/club/{clubId} config-telemetry
|
||||
POST announcements/club/{clubId} config-telemetry
|
||||
DELETE announcements/club/{clubId}/{announcementId} config-telemetry
|
||||
PUT announcements/club/{clubId}/{announcementId} config-telemetry
|
||||
POST announcements/club/{clubId}/{announcementId}/read config-telemetry
|
||||
GET announcements/mine config-telemetry
|
||||
GET announcements/subscription/mine config-telemetry
|
||||
GET announcements/v2/mine/unread config-telemetry
|
||||
GET announcements/v2/mine/unread?sendAnnouncements=true config-telemetry
|
||||
POST api/AppIntegrity/v1/iospaymentqueuefailed commerce-subscription
|
||||
POST api/AppIntegrity/v1/iosproducts commerce-subscription
|
||||
GET api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers commerce-subscription
|
||||
POST api/CampusCard/v1/UpdateAndGetSubscription commerce-subscription
|
||||
POST api/PlayerCheer/v1/SetSelectedCheer progression-misc
|
||||
POST api/PlayerCheer/v1/create progression-misc
|
||||
POST api/PlayerReporting/v1/deviceId moderation-safety
|
||||
POST api/PlayerReporting/v1/hile moderation-safety
|
||||
POST api/PlayerReporting/v1/instantKick moderation-safety
|
||||
GET api/PlayerReporting/v1/moderationBlockDetails moderation-safety
|
||||
GET api/PlayerReporting/v1/voteToKickReasons moderation-safety
|
||||
POST api/PlayerReporting/v3/create moderation-safety
|
||||
POST api/PlayerReporting/v3/voteToKick moderation-safety
|
||||
GET api/activities/charades/v1/words/{0} progression-misc
|
||||
POST api/ageverification/generateCode moderation-safety
|
||||
GET api/announcement/v1/get config-telemetry
|
||||
GET api/apple/musicpromotion/active commerce-subscription
|
||||
GET api/apple/musicpromotion/code commerce-subscription
|
||||
GET api/avatar/v1/defaultbaseavataritems avatar-inventory
|
||||
GET api/avatar/v1/defaultunlocked avatar-inventory
|
||||
GET api/avatar/v1/lockeditems avatar-inventory
|
||||
GET api/avatar/v2 avatar-inventory
|
||||
GET api/avatar/v2/gifts avatar-inventory
|
||||
POST api/avatar/v2/gifts/generate avatar-inventory
|
||||
POST api/avatar/v2/set avatar-inventory
|
||||
GET api/avatar/v2/{playerId} avatar-inventory
|
||||
POST api/avatar/v3/gifts/generate avatar-inventory
|
||||
GET api/avatar/v3/saved avatar-inventory
|
||||
POST api/avatar/v3/saved/set avatar-inventory
|
||||
GET api/avatar/v4/items avatar-inventory
|
||||
GET api/banappeal/generateCode moderation-safety
|
||||
GET api/catalog/v1/all?onlyAvailableSkus=true avatar-inventory
|
||||
GET api/challenge/v2/getCurrent progression-misc
|
||||
POST api/challenge/v2/updateProgress progression-misc
|
||||
POST api/checklist/v1/complete progression-misc
|
||||
GET api/checklist/v1/current progression-misc
|
||||
POST api/clubreporting/v1/report moderation-safety
|
||||
GET api/communityboard/v2/current progression-misc
|
||||
GET api/config/v1/amplitude config-telemetry
|
||||
GET api/config/v1/azurespeech config-telemetry
|
||||
GET api/config/v1/backtrace config-telemetry
|
||||
GET api/config/v1/cohortnux/{cohortId} config-telemetry
|
||||
GET api/config/v1/freegiftbutton config-telemetry
|
||||
GET api/config/v2 config-telemetry
|
||||
POST api/consumables/v1/consume avatar-inventory
|
||||
GET api/consumables/v1/getTransferable/{playerId} avatar-inventory
|
||||
POST api/consumables/v1/transfer avatar-inventory
|
||||
POST api/consumables/v1/updateActive avatar-inventory
|
||||
GET api/consumables/v2/getUnlocked avatar-inventory
|
||||
POST api/customAvatarItems/v1 avatar-inventory
|
||||
POST api/customAvatarItems/v1/bulk avatar-inventory
|
||||
DELETE api/customAvatarItems/v1/design avatar-inventory
|
||||
GET api/customAvatarItems/v1/design avatar-inventory
|
||||
PUT api/customAvatarItems/v1/design avatar-inventory
|
||||
GET api/customAvatarItems/v1/featured avatar-inventory
|
||||
GET api/customAvatarItems/v1/hot avatar-inventory
|
||||
GET api/customAvatarItems/v1/isCreationAllowedForAccount avatar-inventory
|
||||
GET api/customAvatarItems/v1/isCreationEnabled avatar-inventory
|
||||
GET api/customAvatarItems/v1/isRenderingEnabled avatar-inventory
|
||||
GET api/customAvatarItems/v1/me avatar-inventory
|
||||
GET api/customAvatarItems/v1/minPriceForPublicItem avatar-inventory
|
||||
DELETE api/customAvatarItems/v1/{id} avatar-inventory
|
||||
PUT api/customAvatarItems/v1/{id} avatar-inventory
|
||||
POST api/customAvatarItems/v1/{id}/report avatar-inventory
|
||||
GET api/customAvatarItems/v2/fromCreator/{creatorId} avatar-inventory
|
||||
POST api/equipment/v1/update avatar-inventory
|
||||
GET api/equipment/v2/getUnlocked avatar-inventory
|
||||
POST api/externalfriendinvite/v1/createplatforminvite relationships-messaging
|
||||
POST api/externalfriendinvite/v1/getplatformreferrers relationships-messaging
|
||||
POST api/externalfriendinvite/v1/gettextmessagereferrers relationships-messaging
|
||||
POST api/externalfriendinvite/v1/sendtextmessageinvite relationships-messaging
|
||||
POST api/freegifts/v1/sendmultiple progression-misc
|
||||
GET api/gameconfigs/v1/all config-telemetry
|
||||
GET api/gamerewards/v1/pending progression-misc
|
||||
POST api/gamerewards/v1/request progression-misc
|
||||
POST api/gamerewards/v1/select progression-misc
|
||||
POST api/gamesight/event events-playlists
|
||||
POST api/images/v1/cheer inventions-media
|
||||
POST api/images/v1/deletesaved inventions-media
|
||||
GET api/images/v1/listsaved inventions-media
|
||||
POST api/images/v1/modifyaccessibility inventions-media
|
||||
POST api/images/v1/sendlink inventions-media
|
||||
GET api/images/v1/slideshow inventions-media
|
||||
POST api/images/v1/{id}/report inventions-media
|
||||
GET api/images/v2/named inventions-media
|
||||
GET api/images/v4/room/{roomId}?sort&filter&take&skip inventions-media
|
||||
POST api/images/v4/uploadsaved inventions-media
|
||||
GET api/images/v5/bulk inventions-media
|
||||
GET api/images/v5/cheered/bulk inventions-media
|
||||
GET api/images/v5/player/{playerId}?sort={int} inventions-media
|
||||
GET api/images/v6/{id} inventions-media
|
||||
GET api/images/v6?name={imageName} inventions-media
|
||||
POST api/incentivizedreferrals/claim progression-misc
|
||||
GET api/incentivizedreferrals/progress progression-misc
|
||||
GET api/influencerpartnerprogram/influencer progression-misc
|
||||
GET api/influencerpartnerprogram/influencers progression-misc
|
||||
GET api/influencerpartnerprogram/myinfluencer progression-misc
|
||||
POST api/influencerpartnerprogram/remove progression-misc
|
||||
POST api/influencerpartnerprogram/support progression-misc
|
||||
POST api/inventions/v1/cheer inventions-media
|
||||
GET api/inventions/v1/delete?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/details?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/dormskinsfromids inventions-media
|
||||
GET api/inventions/v1/featured inventions-media
|
||||
GET api/inventions/v1/featureddormskins inventions-media
|
||||
GET api/inventions/v1/fromcreators inventions-media
|
||||
GET api/inventions/v1/fulllineageowner inventions-media
|
||||
GET api/inventions/v1/personaldetails/{id} inventions-media
|
||||
POST api/inventions/v1/report inventions-media
|
||||
GET api/inventions/v1/room?id={roomId} inventions-media
|
||||
POST api/inventions/v1/settags inventions-media
|
||||
GET api/inventions/v1/tagfilters inventions-media
|
||||
GET api/inventions/v1/toptoday inventions-media
|
||||
GET api/inventions/v1/unpublish?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/update inventions-media
|
||||
POST api/inventions/v1/updateprice inventions-media
|
||||
GET api/inventions/v1/version?inventionId={id}&version={n} inventions-media
|
||||
GET api/inventions/v1/versions?inventionId={id} inventions-media
|
||||
GET api/inventions/v1?inventionId={id} inventions-media
|
||||
GET api/inventions/v2/batch inventions-media
|
||||
GET api/inventions/v2/mine inventions-media
|
||||
GET api/inventions/v2/search?value&skip&take inventions-media
|
||||
GET api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price inventions-media
|
||||
POST api/inventions/v4/addversion inventions-media
|
||||
POST api/inventions/v6/save inventions-media
|
||||
POST api/keepsakes events-playlists
|
||||
GET api/keepsakes/categories events-playlists
|
||||
GET api/keepsakes/events/{progressionEventId} events-playlists
|
||||
GET api/keepsakes/globalconfig events-playlists
|
||||
GET api/keepsakes/rooms/{roomId} events-playlists
|
||||
DELETE api/keepsakes/{keepsakeInstanceId:guid} events-playlists
|
||||
POST api/keepsakes/{keepsakeInstanceId:guid}/collect events-playlists
|
||||
GET api/messages/v1/favoriteFriendOnlineStatus relationships-messaging
|
||||
POST api/messages/v1/sendMultiple relationships-messaging
|
||||
GET api/messages/v2/get relationships-messaging
|
||||
POST api/messages/v2/send relationships-messaging
|
||||
POST api/messages/v3/delete relationships-messaging
|
||||
POST api/objectives/v1/cleargroup progression-misc
|
||||
POST api/objectives/v1/completegroup progression-misc
|
||||
GET api/objectives/v1/myprogress progression-misc
|
||||
POST api/objectives/v1/updateobjective progression-misc
|
||||
POST api/offlineinvite/v1/send relationships-messaging
|
||||
GET api/playerevents/v1 events-playlists
|
||||
GET api/playerevents/v1/all events-playlists
|
||||
GET api/playerevents/v1/all/{0} events-playlists
|
||||
POST api/playerevents/v1/broadcast events-playlists
|
||||
POST api/playerevents/v1/bulk events-playlists
|
||||
POST api/playerevents/v1/bulkInvite events-playlists
|
||||
GET api/playerevents/v1/club/{0} events-playlists
|
||||
GET api/playerevents/v1/clubs events-playlists
|
||||
POST api/playerevents/v1/deleteResponse events-playlists
|
||||
POST api/playerevents/v1/report events-playlists
|
||||
POST api/playerevents/v1/respond events-playlists
|
||||
GET api/playerevents/v1/room/{0} events-playlists
|
||||
GET api/playerevents/v1/search events-playlists
|
||||
GET api/playerevents/v1/searchlive events-playlists
|
||||
GET api/playerevents/v1/tagfilters events-playlists
|
||||
GET api/playerevents/v1/{0} events-playlists
|
||||
GET api/playerevents/v1/{0}/responses events-playlists
|
||||
POST api/playerevents/v2 events-playlists
|
||||
POST api/playerevents/v2/delete/{0} events-playlists
|
||||
POST api/playerevents/v2/{0} events-playlists
|
||||
PUT api/playerevents/v2/{0}/accessibility events-playlists
|
||||
PUT api/playerevents/v2/{0}/club events-playlists
|
||||
PUT api/playerevents/v2/{0}/description events-playlists
|
||||
PUT api/playerevents/v2/{0}/image events-playlists
|
||||
PUT api/playerevents/v2/{0}/multiinstance events-playlists
|
||||
PUT api/playerevents/v2/{0}/name events-playlists
|
||||
PUT api/playerevents/v2/{0}/room events-playlists
|
||||
PUT api/playerevents/v2/{0}/tags events-playlists
|
||||
PUT api/playerevents/v2/{0}/time events-playlists
|
||||
PUT api/players/v4/current/contact identity-account
|
||||
POST api/playerwarnings moderation-safety
|
||||
POST api/playerwarnings/acknowledge moderation-safety
|
||||
POST api/playstationplus/expire commerce-subscription
|
||||
GET api/progressionEvents/active events-playlists
|
||||
POST api/progressionEvents/collect/{eventId}/{rewardIndex} events-playlists
|
||||
GET api/progressionEvents/event/{eventId} events-playlists
|
||||
GET api/progressionEvents/record/{progressionEventId} events-playlists
|
||||
GET api/progressionEvents/{eventId}/xpboosts events-playlists
|
||||
GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp events-playlists
|
||||
GET api/quickPlay/v1/getandclear progression-misc
|
||||
GET api/relationships/mutualfriends relationships-messaging
|
||||
POST api/relationships/sendfriendintroductions relationships-messaging
|
||||
GET api/relationships/v1/addfriendwithcode relationships-messaging
|
||||
POST api/relationships/v1/bulkignoreplatformusers relationships-messaging
|
||||
GET api/relationships/v1/favorite relationships-messaging
|
||||
POST api/relationships/v1/ignore moderation-safety
|
||||
POST api/relationships/v1/mute moderation-safety
|
||||
GET api/relationships/v1/unfavorite relationships-messaging
|
||||
POST api/relationships/v1/unignore moderation-safety
|
||||
POST api/relationships/v1/unmute moderation-safety
|
||||
GET api/relationships/v2/acceptfriendrequest relationships-messaging
|
||||
GET api/relationships/v2/addfriend relationships-messaging
|
||||
GET api/relationships/v2/get relationships-messaging
|
||||
GET api/relationships/v2/removefriend relationships-messaging
|
||||
GET api/relationships/v2/sendfriendrequest relationships-messaging
|
||||
POST api/roomCurrencies/v2/purchase room-economy
|
||||
POST api/roomEarningsDistributions/v1/earningsDistribution progression-misc
|
||||
PUT api/roomconsumables/v1/roomConsumable room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/room/{roomId} room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me room-economy
|
||||
DELETE api/roomconsumables/v1/roomConsumable/{id} room-economy
|
||||
PUT api/roomconsumables/v1/roomConsumable/{id}/consume room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/{id}/isOwned room-economy
|
||||
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency room-economy
|
||||
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens room-economy
|
||||
POST api/roomcurrencies/v1/awardCurrency/bulk room-economy
|
||||
POST api/roomcurrencies/v1/createCurrency room-economy
|
||||
POST api/roomcurrencies/v1/createPurchaseOffer room-economy
|
||||
GET api/roomcurrencies/v1/currencies room-economy
|
||||
POST api/roomcurrencies/v1/deletePurchaseOffer room-economy
|
||||
GET api/roomcurrencies/v1/getAllBalances room-economy
|
||||
GET api/roomcurrencies/v1/getBalance room-economy
|
||||
GET api/roomcurrencies/v1/getPurchaseOffersBatch room-economy
|
||||
POST api/roomcurrencies/v1/updateCurrency room-economy
|
||||
POST api/roomcurrencies/v1/updatePurchaseOffer room-economy
|
||||
POST api/roomkeys/v1/create room-economy
|
||||
DELETE api/roomkeys/v1/delete/{roomKeyId} room-economy
|
||||
GET api/roomkeys/v1/mine room-economy
|
||||
GET api/roomkeys/v1/owns room-economy
|
||||
POST api/roomkeys/v1/owns/bulk room-economy
|
||||
GET api/roomkeys/v1/purchased/{roomKeyId} room-economy
|
||||
GET api/roomkeys/v1/room?roomId={id} room-economy
|
||||
GET api/rooms/v1/filters rooms
|
||||
POST api/rooms/v1/verifyRole rooms
|
||||
POST api/rooms/v2/report rooms
|
||||
GET api/royale/v1/current progression-misc
|
||||
POST api/royale/v2/matchcomplete progression-misc
|
||||
POST api/sanitize/v1 identity-account
|
||||
POST api/sanitize/v1/isPure identity-account
|
||||
POST api/screensharereports/v1/report moderation-safety
|
||||
POST api/storefronts/v1/PurchaseRoomKeyWithCurrency commerce-subscription
|
||||
GET api/storefronts/v1/adcarouselitems commerce-subscription
|
||||
GET api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int} commerce-subscription
|
||||
POST api/storefronts/v1/buyForFreeGiftButton commerce-subscription
|
||||
POST api/storefronts/v1/buyProgressionEventXpBoost commerce-subscription
|
||||
POST api/storefronts/v1/buyPurchaseReminder commerce-subscription
|
||||
GET api/storefronts/v1/buyRoomKey commerce-subscription
|
||||
POST api/storefronts/v1/objectives commerce-subscription
|
||||
GET api/storefronts/v1/season/{storefrontType:int} commerce-subscription
|
||||
GET api/storefronts/v1/toptoday commerce-subscription
|
||||
POST api/storefronts/v1/trialInvention commerce-subscription
|
||||
GET api/storefronts/v1/trialInvention/duration commerce-subscription
|
||||
POST api/storefronts/v2/balance commerce-subscription
|
||||
POST api/storefronts/v2/buyElite commerce-subscription
|
||||
GET api/storefronts/v2/buyInvention commerce-subscription
|
||||
POST api/storefronts/v2/buyItem commerce-subscription
|
||||
POST api/storefronts/v2/buyTier commerce-subscription
|
||||
GET api/storefronts/v3/giftdropstore/{storefrontType:int} commerce-subscription
|
||||
GET api/storefronts/v4/balance/{currencyType:int} commerce-subscription
|
||||
GET api/subscriptionseasons/v1/seasons/current progression-misc
|
||||
GET api/testcasemanagement/v1/testcase/{id} config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/claim config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/comment config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/status config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/unclaim config-telemetry
|
||||
GET api/testcasemanagement/v1/testpass/{id} config-telemetry
|
||||
GET api/testcasemanagement/v1/testpasssummary config-telemetry
|
||||
POST api/ugcPurchasables/v1/items/bulk avatar-inventory
|
||||
GET api/ugcPurchasables/v1/items/room/{roomId} avatar-inventory
|
||||
GET api/versioncheck/v4 identity-account
|
||||
DELETE cachedlogin/current identity-account
|
||||
GET cachedlogin/forplatformid/{0}/{1} identity-account
|
||||
POST cachedlogin/forplatformids identity-account
|
||||
POST cachedlogin/migrate identity-account
|
||||
GET club/account/{0}/created social-clubs-chat
|
||||
GET club/categoryTags social-clubs-chat
|
||||
POST club/create social-clubs-chat
|
||||
DELETE club/home/me social-clubs-chat
|
||||
GET club/home/me social-clubs-chat
|
||||
PUT club/home/me social-clubs-chat
|
||||
GET club/mine/created social-clubs-chat
|
||||
GET club/mine/member social-clubs-chat
|
||||
GET club/mostactivetoday social-clubs-chat
|
||||
GET club/search social-clubs-chat
|
||||
DELETE club/{0} social-clubs-chat
|
||||
GET club/{0} social-clubs-chat
|
||||
DELETE club/{0}/additionalimage/{1} social-clubs-chat
|
||||
PUT club/{0}/additionalimage/{1} social-clubs-chat
|
||||
PUT club/{0}/clubChatEnabled social-clubs-chat
|
||||
DELETE club/{0}/clubhouse social-clubs-chat
|
||||
PUT club/{0}/clubhouse social-clubs-chat
|
||||
GET club/{0}/details social-clubs-chat
|
||||
GET club/{0}/hasDisabledClubChat social-clubs-chat
|
||||
DELETE club/{0}/mainimage social-clubs-chat
|
||||
PUT club/{0}/mainimage social-clubs-chat
|
||||
GET club/{0}/members social-clubs-chat
|
||||
POST club/{0}/members/acceptinvite social-clubs-chat
|
||||
POST club/{0}/members/acceptrequest social-clubs-chat
|
||||
POST club/{0}/members/acceptrequests social-clubs-chat
|
||||
POST club/{0}/members/ban social-clubs-chat
|
||||
GET club/{0}/members/banned social-clubs-chat
|
||||
PUT club/{0}/members/changetype social-clubs-chat
|
||||
POST club/{0}/members/declineinvite social-clubs-chat
|
||||
POST club/{0}/members/denyrequest social-clubs-chat
|
||||
POST club/{0}/members/denyrequests social-clubs-chat
|
||||
POST club/{0}/members/directJoin social-clubs-chat
|
||||
PUT club/{0}/members/invite social-clubs-chat
|
||||
POST club/{0}/members/invitemembers social-clubs-chat
|
||||
POST club/{0}/members/leave social-clubs-chat
|
||||
POST club/{0}/members/remove social-clubs-chat
|
||||
GET club/{0}/members/requests social-clubs-chat
|
||||
GET club/{0}/members/requests/search social-clubs-chat
|
||||
PUT club/{0}/members/requesttojoin social-clubs-chat
|
||||
GET club/{0}/members/search social-clubs-chat
|
||||
POST club/{0}/members/unban social-clubs-chat
|
||||
GET club/{0}/members/{1} social-clubs-chat
|
||||
PUT club/{0}/minlevel social-clubs-chat
|
||||
PUT club/{0}/modify social-clubs-chat
|
||||
PUT club/{0}/modifydetails social-clubs-chat
|
||||
PUT club/{0}/permissions/{1} social-clubs-chat
|
||||
GET clubhousesearch/mostactivenow social-clubs-chat
|
||||
POST comments/create/{0} social-clubs-chat
|
||||
DELETE comments/delete/{0} social-clubs-chat
|
||||
GET comments/get/{0} social-clubs-chat
|
||||
PUT comments/read/{0}/{1} social-clubs-chat
|
||||
GET comments/unreadcounts social-clubs-chat
|
||||
GET config/categories config-telemetry
|
||||
POST connect/deviceauthorization identity-account
|
||||
POST connect/token identity-account
|
||||
POST data/event events-playlists
|
||||
PUT datalink config-telemetry
|
||||
GET datalink/{code} config-telemetry
|
||||
GET eac/challenge identity-account
|
||||
GET econ/customAvatarItems/v1/itemOwnershipLimit avatar-inventory
|
||||
GET econ/customAvatarItems/v1/owned avatar-inventory
|
||||
POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} avatar-inventory
|
||||
GET emojiConfig/whitelistedEmojis config-telemetry
|
||||
GET event/{0}/instances events-playlists
|
||||
GET featuredrooms/current rooms
|
||||
POST feed/query config-telemetry
|
||||
DELETE invite/{0} progression-misc
|
||||
POST leaderboard/CheckAndSetStat progression-misc
|
||||
POST leaderboard/GetNearbyScores progression-misc
|
||||
POST leaderboard/GetPlayerRank progression-misc
|
||||
POST leaderboard/GetRanks progression-misc
|
||||
POST matchmake/chatinvite/{0}/{1} matchmaking-session
|
||||
POST matchmake/club/{0} matchmaking-session
|
||||
POST matchmake/code/{0}/{1} matchmaking-session
|
||||
POST matchmake/dorm matchmaking-session
|
||||
POST matchmake/event/{0} matchmaking-session
|
||||
POST matchmake/instance/{0} matchmaking-session
|
||||
POST matchmake/invite/{0} matchmaking-session
|
||||
POST matchmake/none matchmaking-session
|
||||
POST matchmake/player/{0} matchmaking-session
|
||||
POST matchmake/room/{0} matchmaking-session
|
||||
POST matchmake/room/{0}/{1} matchmaking-session
|
||||
GET members/bulk social-clubs-chat
|
||||
GET namegen/options identity-account
|
||||
POST pageview/consume config-telemetry
|
||||
GET parentalcontrol/me identity-account
|
||||
GET platformid/{0} identity-account
|
||||
GET player/avoidjuniors matchmaking-session
|
||||
POST player/exclusivelogin matchmaking-session
|
||||
POST player/heartbeat matchmaking-session
|
||||
POST player/login matchmaking-session
|
||||
POST player/logout matchmaking-session
|
||||
POST player/notifydisconnect matchmaking-session
|
||||
PUT player/photonregionpings matchmaking-session
|
||||
PUT player/statusvisibility matchmaking-session
|
||||
PUT player/vrmovementmode matchmaking-session
|
||||
POST playlists/bulk events-playlists
|
||||
GET playlists/cheeredby/me events-playlists
|
||||
GET playlists/createdby/me events-playlists
|
||||
GET playlists/favoritedby/me events-playlists
|
||||
GET playlists/visitedby/me events-playlists
|
||||
DELETE playlists/{0} events-playlists
|
||||
GET playlists/{0} events-playlists
|
||||
PUT playlists/{0}/accessibility events-playlists
|
||||
PUT playlists/{0}/description events-playlists
|
||||
PUT playlists/{0}/image events-playlists
|
||||
GET playlists/{0}/interactionby/me events-playlists
|
||||
PUT playlists/{0}/interactionby/me/cheer events-playlists
|
||||
PUT playlists/{0}/interactionby/me/favorite events-playlists
|
||||
PUT playlists/{0}/levelvoting events-playlists
|
||||
PUT playlists/{0}/name events-playlists
|
||||
PUT playlists/{0}/restrictions events-playlists
|
||||
PUT playlists/{0}/rooms/{1} events-playlists
|
||||
PUT playlists/{0}/tags events-playlists
|
||||
PUT playlists/{0}/warning events-playlists
|
||||
POST purchase/v1/cancelpurchase commerce-subscription
|
||||
POST purchase/v1/cleanuppending commerce-subscription
|
||||
POST purchase/v1/completepurchase commerce-subscription
|
||||
GET purchase/v1/hasspentmoney commerce-subscription
|
||||
POST purchase/v1/initiatepurchase commerce-subscription
|
||||
POST purchase/v1/processpurchase commerce-subscription
|
||||
GET purchasecampaign/allcurrent/v2 commerce-subscription
|
||||
POST purchasecampaign/shown commerce-subscription
|
||||
GET reminder/currentTokenBundles/v2 commerce-subscription
|
||||
POST remote-run/push-to-studio inventions-media
|
||||
GET role/developer/{0} progression-misc
|
||||
GET role/moderator/{0} progression-misc
|
||||
GET room/{0}/instances rooms
|
||||
PUT roominstance/{0}/inprogress rooms
|
||||
PUT roominstance/{0}/markprivate rooms
|
||||
POST roominstance/{0}/reportjoinresult rooms
|
||||
PUT roominstance/{0}/roomCode rooms
|
||||
GET rooms/base rooms
|
||||
GET rooms/bulk rooms
|
||||
GET rooms/cheeredby/me rooms
|
||||
GET rooms/contestwinners rooms
|
||||
GET rooms/createdby/me rooms
|
||||
GET rooms/createdby/{0} rooms
|
||||
GET rooms/curated_playlists rooms
|
||||
GET rooms/favoritedby/me rooms
|
||||
GET rooms/fromcreators rooms
|
||||
GET rooms/hot rooms
|
||||
GET rooms/magic_door rooms
|
||||
GET rooms/moderatedby/me rooms
|
||||
GET rooms/ownedby/me rooms
|
||||
GET rooms/ownedby/{0} rooms
|
||||
GET rooms/recommendations rooms
|
||||
GET rooms/rro_ids rooms
|
||||
GET rooms/search rooms
|
||||
GET rooms/topcreators rooms
|
||||
GET rooms/visitedby/me rooms
|
||||
GET rooms/visitedby/{0} rooms
|
||||
DELETE rooms/{0} rooms
|
||||
GET rooms/{0} rooms
|
||||
PUT rooms/{0}/accessibility rooms
|
||||
PUT rooms/{0}/allow_new_users rooms
|
||||
PUT rooms/{0}/automute rooms
|
||||
GET rooms/{0}/bans rooms
|
||||
POST rooms/{0}/bans rooms
|
||||
POST rooms/{0}/bans/import rooms
|
||||
DELETE rooms/{0}/bans/{1} rooms
|
||||
POST rooms/{0}/clone rooms
|
||||
PUT rooms/{0}/cloning rooms
|
||||
PUT rooms/{0}/comments rooms
|
||||
PUT rooms/{0}/creator rooms
|
||||
PUT rooms/{0}/description rooms
|
||||
PUT rooms/{0}/image rooms
|
||||
GET rooms/{0}/interactionby/me rooms
|
||||
DELETE rooms/{0}/interactionby/me/cheer rooms
|
||||
PUT rooms/{0}/interactionby/me/cheer rooms
|
||||
DELETE rooms/{0}/interactionby/me/favorite rooms
|
||||
PUT rooms/{0}/interactionby/me/favorite rooms
|
||||
DELETE rooms/{0}/loadscreen rooms
|
||||
PUT rooms/{0}/loadscreen rooms
|
||||
PUT rooms/{0}/max_player_calculation_mode rooms
|
||||
PUT rooms/{0}/min_level rooms
|
||||
PUT rooms/{0}/modify rooms
|
||||
PUT rooms/{0}/name rooms
|
||||
GET rooms/{0}/playerdata/me rooms
|
||||
PUT rooms/{0}/playerdata/me rooms
|
||||
DELETE rooms/{0}/promo_external rooms
|
||||
PUT rooms/{0}/promo_external rooms
|
||||
DELETE rooms/{0}/promo_images rooms
|
||||
PUT rooms/{0}/promo_images rooms
|
||||
PUT rooms/{0}/restrictions rooms
|
||||
GET rooms/{0}/roles rooms
|
||||
GET rooms/{0}/roles/{1} rooms
|
||||
PUT rooms/{0}/roles/{1} rooms
|
||||
PUT rooms/{0}/roles/{1}/invite rooms
|
||||
GET rooms/{0}/similar rooms
|
||||
POST rooms/{0}/subrooms rooms
|
||||
DELETE rooms/{0}/subrooms/{1} rooms
|
||||
PUT rooms/{0}/subrooms/{1}/accessibility rooms
|
||||
POST rooms/{0}/subrooms/{1}/clone rooms
|
||||
POST rooms/{0}/subrooms/{1}/data rooms
|
||||
PUT rooms/{0}/subrooms/{1}/maxplayers rooms
|
||||
PUT rooms/{0}/subrooms/{1}/modify rooms
|
||||
POST rooms/{0}/subrooms/{1}/move rooms
|
||||
PUT rooms/{0}/subrooms/{1}/name rooms
|
||||
PUT rooms/{0}/subrooms/{1}/permissions rooms
|
||||
POST rooms/{0}/subrooms/{1}/publish_save rooms
|
||||
GET rooms/{0}/subrooms/{1}/saves rooms
|
||||
PUT rooms/{0}/tags rooms
|
||||
PUT rooms/{0}/voice_chat_encryption rooms
|
||||
PUT rooms/{0}/warning rooms
|
||||
GET settings/partyinvite config-telemetry
|
||||
GET showcase/{0} inventions-media
|
||||
GET subscription/details/{accountId:int} commerce-subscription
|
||||
GET subscription/mine/member commerce-subscription
|
||||
GET subscription/subscriberCount/{accountId:int} commerce-subscription
|
||||
GET subscription/top/creators/today commerce-subscription
|
||||
POST subscription/v1/cancel commerce-subscription
|
||||
POST subscription/{accountId:int} commerce-subscription
|
||||
GET thread/club/{0} social-clubs-chat
|
||||
PUT thread/message/{0}/moderate social-clubs-chat
|
||||
POST thread/withmembers social-clubs-chat
|
||||
GET thread/{0} social-clubs-chat
|
||||
POST thread/{0} social-clubs-chat
|
||||
PUT thread/{0}/favorite social-clubs-chat
|
||||
POST thread/{0}/leave social-clubs-chat
|
||||
POST thread/{0}/member/{1} social-clubs-chat
|
||||
GET thread/{0}/message social-clubs-chat
|
||||
POST thread/{0}/message/{1}/read social-clubs-chat
|
||||
POST thread/{0}/rename social-clubs-chat
|
||||
POST thread/{0}/snooze social-clubs-chat
|
||||
GET unity_assets/{0}/{1}/{2} inventions-media
|
||||
GET voice/config moderation-safety
|
||||
GET voice/requiresModeration moderation-safety
|
||||
|
@@ -0,0 +1,142 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace DorkNet.Server.Binding;
|
||||
|
||||
/// <summary>
|
||||
/// Binds an action's request DTO from either a form-urlencoded body or a JSON
|
||||
/// body, whichever the caller actually sent.
|
||||
///
|
||||
/// The Rec Room client's HTTP layer (<c>BNDIAONDFFF</c> in RecNet.Runtime)
|
||||
/// sends nearly every write as <c>application/x-www-form-urlencoded</c>, adding
|
||||
/// fields one at a time. Handlers written against <c>[FromBody]</c> only accept
|
||||
/// JSON, so with <c>[ApiController]</c> those requests were rejected with 415
|
||||
/// Unsupported Media Type before the action body ever ran — this is why every
|
||||
/// club member-management action (invite, ban, kick, promote, approve, modify)
|
||||
/// failed on the 2023 client.
|
||||
///
|
||||
/// Rather than hand-rolling a form reader per handler, apply this binder to the
|
||||
/// DTO parameter:
|
||||
///
|
||||
/// <code>
|
||||
/// public Task<IActionResult> MemberBan(
|
||||
/// long clubId,
|
||||
/// [ModelBinder(typeof(FormOrJsonModelBinder))] MemberTargetRequest req)
|
||||
/// </code>
|
||||
///
|
||||
/// The explicit <c>[ModelBinder]</c> is required: without it <c>[ApiController]</c>
|
||||
/// infers <c>[FromBody]</c> for complex types and this binder never runs.
|
||||
///
|
||||
/// Matching is case-insensitive on both paths, and a scalar property also
|
||||
/// accepts the singular/plural spellings the client uses interchangeably. A
|
||||
/// missing or unparseable body yields a default-constructed DTO rather than a
|
||||
/// 400, matching how the client treats these calls as fire-and-forget.
|
||||
/// </summary>
|
||||
public sealed class FormOrJsonModelBinder : IModelBinder
|
||||
{
|
||||
public async Task BindModelAsync(ModelBindingContext bindingContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindingContext);
|
||||
|
||||
var type = bindingContext.ModelType;
|
||||
var request = bindingContext.HttpContext.Request;
|
||||
var model = Activator.CreateInstance(type)!;
|
||||
|
||||
if (request.HasFormContentType)
|
||||
{
|
||||
var form = await request.ReadFormAsync(bindingContext.HttpContext.RequestAborted);
|
||||
foreach (var prop in Writable(type))
|
||||
{
|
||||
var values = Lookup(form, prop.Name);
|
||||
if (values.Count == 0) continue;
|
||||
var converted = Convert(values, prop.PropertyType);
|
||||
if (converted is not null) prop.SetValue(model, converted);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
request.EnableBuffering();
|
||||
request.Body.Position = 0;
|
||||
var parsed = await JsonSerializer.DeserializeAsync(
|
||||
request.Body, type,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
|
||||
bindingContext.HttpContext.RequestAborted);
|
||||
request.Body.Position = 0;
|
||||
if (parsed is not null) model = parsed;
|
||||
}
|
||||
catch (JsonException) { /* empty or non-JSON body → defaults */ }
|
||||
catch (NotSupportedException) { /* unreadable body → defaults */ }
|
||||
}
|
||||
|
||||
bindingContext.Result = ModelBindingResult.Success(model);
|
||||
}
|
||||
|
||||
private static IEnumerable<PropertyInfo> Writable(Type type) =>
|
||||
type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanWrite);
|
||||
|
||||
/// <summary>Form keys, case-insensitively, also accepting the other
|
||||
/// number of the name — the client says <c>accountId</c> where the DTO says
|
||||
/// <c>AccountIds</c> and vice versa depending on the endpoint.</summary>
|
||||
private static List<string> Lookup(IFormCollection form, string propertyName)
|
||||
{
|
||||
var candidates = new List<string> { propertyName };
|
||||
if (propertyName.EndsWith('s')) candidates.Add(propertyName[..^1]);
|
||||
else candidates.Add(propertyName + "s");
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
foreach (var key in form.Keys)
|
||||
{
|
||||
if (!string.Equals(key, candidate, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var values = form[key]
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v!)
|
||||
.ToList();
|
||||
if (values.Count > 0) return values;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private static object? Convert(List<string> values, Type target)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(target) ?? target;
|
||||
|
||||
if (underlying.IsGenericType && underlying.GetGenericTypeDefinition() == typeof(List<>))
|
||||
{
|
||||
var element = underlying.GetGenericArguments()[0];
|
||||
var list = (System.Collections.IList)Activator.CreateInstance(underlying)!;
|
||||
// A repeated field arrives as multiple values; a single field may
|
||||
// still carry a comma-separated list.
|
||||
foreach (var raw in values.SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)))
|
||||
{
|
||||
var item = ConvertScalar(raw, element);
|
||||
if (item is not null) list.Add(item);
|
||||
}
|
||||
return list.Count > 0 ? list : null;
|
||||
}
|
||||
|
||||
return ConvertScalar(values[0], underlying);
|
||||
}
|
||||
|
||||
private static object? ConvertScalar(string raw, Type target)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (target == typeof(string)) return raw;
|
||||
if (target == typeof(bool)) return bool.TryParse(raw, out var b) ? b : raw == "1";
|
||||
if (target.IsEnum) return Enum.TryParse(target, raw, ignoreCase: true, out var e) ? e : null;
|
||||
if (target == typeof(Guid)) return Guid.TryParse(raw, out var g) ? g : null;
|
||||
if (target == typeof(DateTime)) return DateTime.TryParse(raw, out var d) ? d : null;
|
||||
return System.Convert.ChangeType(raw, target, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,11 @@ public class AccountApiController(
|
||||
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? auth["Bearer ".Length..].Trim()
|
||||
: Request.Cookies[AuthService.AccessCookieName];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
@@ -201,12 +203,12 @@ public class AccountApiController(
|
||||
|
||||
[HttpPost("api/account/v1/profileimage")]
|
||||
[HttpPost("api/account/me/profileimage")]
|
||||
[HttpPost("account/me/profileimage")]
|
||||
[HttpPost("profileimage")]
|
||||
[HttpPost("account/me/profileimage", Order = 10)]
|
||||
[HttpPost("profileimage", Order = 10)]
|
||||
[HttpPut("api/account/v1/profileimage")]
|
||||
[HttpPut("api/account/me/profileimage")]
|
||||
[HttpPut("account/me/profileimage")]
|
||||
[HttpPut("profileimage")]
|
||||
[HttpPut("account/me/profileimage", Order = 10)]
|
||||
[HttpPut("profileimage", Order = 10)]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SetProfileImage(
|
||||
[FromForm(Name = "imageName")] string? imageName,
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Activities;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/activities/* — per-activity content
|
||||
/// (charades words, party-game prompts, etc.). The 2020 watch
|
||||
/// fetches these as a static list at room-join time; we ship an
|
||||
/// empty list so the activity's bundled defaults kick in.
|
||||
/// (charades words, party-game prompts, etc.).
|
||||
///
|
||||
/// The March 2023 watch fetches a 3D Charades deck at card-box spawn from
|
||||
/// <c>api/activities/charades/v1/words/{source}</c>, where <c>{source}</c>
|
||||
/// is one of three baked <c>CardBox.cardSource</c> slots — <c>Charades</c>,
|
||||
/// <c>CharadesAprilFoolsDay</c>, or <c>Icebreakers</c> (verified in the
|
||||
/// 2023.03.21 il2cpp dump). We resolve the admin-bound word list for the
|
||||
/// requested slot via <see cref="CharadesWordListService"/> and return the
|
||||
/// wire array of <c>{ EN_US, Difficulty }</c>. The older December route
|
||||
/// (no <c>{source}</c>) maps to the default Charades slot.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ActivitiesController : ControllerBase
|
||||
public class ActivitiesController(CharadesWordListService charades) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/activities/charades/v1/words/{source}")]
|
||||
public async Task<IActionResult> CharadesWords(string source)
|
||||
=> Ok(await charades.ResolveWireWordsAsync(source));
|
||||
|
||||
/// <summary>December 2020 shape — no source segment. Serves the default
|
||||
/// Charades slot so the older client still gets a populated deck.</summary>
|
||||
[HttpGet("api/activities/charades/v1/words")]
|
||||
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
|
||||
public async Task<IActionResult> CharadesWordsDefault()
|
||||
=> Ok(await charades.ResolveWireWordsAsync(
|
||||
nameof(CharadesWordListService.CharadesSlot.Charades)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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.AgeVerification;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ageverification")]
|
||||
[Authorize]
|
||||
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>The 2023-03-21 client POSTs this (verb register <c>rdx=2</c>
|
||||
/// at RecNet.Runtime/ALLHCFFLMLD.txt:216-222) and deserialises the body as
|
||||
/// a bare <c>String</c>: the issuing method is
|
||||
/// <c>FGLDKEJLAKB<IPCJLCNIBEG<System.String>> GKEBPOPAEJC()</c>
|
||||
/// (:150). Returning a JSON object here made Json.NET throw, so the
|
||||
/// promise rejected and the code was never shown in the watch.
|
||||
/// GET stays registered because it costs nothing.</summary>
|
||||
[HttpPost("generateCode")]
|
||||
[HttpGet("generateCode")]
|
||||
public async Task<IActionResult> GenerateCode()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"ageverification:{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
Value = code,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Content(JsonSerializer.Serialize(code), "application/json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.AppIntegrity;
|
||||
|
||||
[ApiController]
|
||||
public class AppIntegrityController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/AppIntegrity/v1/iosproducts")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> IosProducts()
|
||||
{
|
||||
var rows = await db.StoreItems
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.Id)
|
||||
.Take(100)
|
||||
.Select(i => new
|
||||
{
|
||||
ProductId = i.Slug,
|
||||
i.Price,
|
||||
i.CurrencyType,
|
||||
Name = i.DisplayName,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpPost("api/AppIntegrity/v1/iospaymentqueuefailed")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IosPaymentQueueFailed([FromForm] string? reason)
|
||||
{
|
||||
db.BugReports.Add(new BugReportEntity
|
||||
{
|
||||
ReporterPlayerId = this.RequireCurrentPlayerId(),
|
||||
Title = "iOS payment queue failed",
|
||||
Body = reason ?? Request.Query["reason"].FirstOrDefault() ?? string.Empty,
|
||||
Category = "appintegrity",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Apple;
|
||||
|
||||
[ApiController]
|
||||
public class AppleMusicPromotionController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/apple/musicpromotion/active")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Active()
|
||||
{
|
||||
var start = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var end = start.AddYears(1);
|
||||
return Ok(new { Active = true, StartAt = start, EndAt = end });
|
||||
}
|
||||
|
||||
[HttpPost("api/apple/musicpromotion/code")]
|
||||
[HttpGet("api/apple/musicpromotion/code")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Code()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "applemusic:promotion_code");
|
||||
if (row is null)
|
||||
{
|
||||
row = new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "applemusic:promotion_code",
|
||||
Value = $"RR-{Guid.NewGuid():N}"[..16],
|
||||
};
|
||||
db.PlayerSettings.Add(row);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new { Code = row.Value, Redeemed = false });
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,10 @@ namespace DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
public class AvatarGiftsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
LevelService level) : ControllerBase
|
||||
LevelService level,
|
||||
StoreService store,
|
||||
QuestRewardService questRewards,
|
||||
PlayerPresenceService presence) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/avatar/v2/gifts")]
|
||||
[HttpGet("api/avatar/v3/gifts")]
|
||||
@@ -47,26 +50,46 @@ public class AvatarGiftsController(
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record GenerateGiftRequest(int? GiftContext);
|
||||
public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message);
|
||||
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — server picks
|
||||
/// a random unlocked-avatar item and queues a gift package. The
|
||||
/// 2020 client posts to this when the user clicks a free-gift
|
||||
/// tile or hits a context the client maps to <c>GiftContext</c>.</summary>
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of-
|
||||
/// activity gift box (chest). For a game/quest gift (IsGameGift) the
|
||||
/// server first tries the quest's configured item pool, keyed by the
|
||||
/// RRO room the player is in (the request carries no quest id). If
|
||||
/// that room has no pool — or for non-game free-gift tiles — it falls
|
||||
/// back to a random wardrobe item. The rolled item is stamped as a
|
||||
/// gift package; opening the box (<c>gifts/consume</c>) grants it.</summary>
|
||||
// The 2023 client posts the gift-chest generate to v3 (GET gifts is
|
||||
// already fanned across v2/v3/v4, but generate only routed v2 — the v3
|
||||
// POST 404'd, breaking reward chests on that path). Same request body.
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
[HttpPost("api/avatar/v3/gifts/generate")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
// Pick a random wardrobe StoreItem (Slug starts with
|
||||
// "wardrobe-" — that's the convention StoreService uses for
|
||||
// catalogue rows derived from outfits_assets bundles).
|
||||
var isGameGift = req?.IsGameGift ?? false;
|
||||
var rng = new Random();
|
||||
|
||||
// Quest chest: pick from this room's configured reward pool.
|
||||
if (isGameGift && presence.GetRoom(pid) is { RoomId: > 0 } room
|
||||
&& await questRewards.PickForRoomAsync(room.RoomId, rng.Next()) is { } questItem)
|
||||
{
|
||||
var gift = BuildGiftFromItem(pid, questItem, req?.GiftContext ?? 0, rng);
|
||||
gift.SourceStoreItemId = questItem.Id;
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.NotifyAsync(pid, PushNotificationId.GiftPackageReceived, ToWire(gift));
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
// Fallback: random wardrobe StoreItem (Slug "wardrobe-{guid}").
|
||||
var pool = await db.StoreItems
|
||||
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
|
||||
.Select(s => new { s.Slug, s.DisplayName })
|
||||
.ToListAsync();
|
||||
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
|
||||
|
||||
var rng = new Random();
|
||||
var pick = pool[rng.Next(pool.Count)];
|
||||
var guid = pick.Slug["wardrobe-".Length..];
|
||||
|
||||
@@ -74,7 +97,7 @@ public class AvatarGiftsController(
|
||||
// so the watch's parse-and-iterate doesn't blow up in
|
||||
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
|
||||
// for the canonical wire-format note.
|
||||
var gift = new GiftPackageEntity
|
||||
var randomGift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
FromPlayerId = null,
|
||||
@@ -90,15 +113,57 @@ public class AvatarGiftsController(
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
db.GiftPackages.Add(randomGift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push so the watch shows the new-gift toast.
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.GiftPackageReceived, ToWire(gift));
|
||||
return Ok(ToWire(gift));
|
||||
PushNotificationId.GiftPackageReceived, ToWire(randomGift));
|
||||
return Ok(ToWire(randomGift));
|
||||
}
|
||||
|
||||
/// <summary>Build a gift package for a specific store item, filling
|
||||
/// the wire payload that matches its kind (avatar item vs equipment
|
||||
/// vs consumable) so <c>gifts/consume</c> grants the right thing.</summary>
|
||||
private static GiftPackageEntity BuildGiftFromItem(long pid, GiftPackageItem item, int giftContext, Random rng)
|
||||
{
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
FromPlayerId = null,
|
||||
CurrencyType = 0,
|
||||
GiftContext = giftContext,
|
||||
GiftRarity = RollRarity(rng),
|
||||
Message = $"You earned {item.DisplayName}!",
|
||||
Platform = -1,
|
||||
PackageVariant = "Standard",
|
||||
PackageMaterial = string.Empty,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
|
||||
if (StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc))
|
||||
{
|
||||
gift.AvatarItemType = avatarItemType;
|
||||
gift.AvatarItemDescOrHairDyeDesc = avatarItemDesc;
|
||||
}
|
||||
else if (StoreService.TryGetEquipmentPayload(item.Slug, out var prefab, out var modGuid, out _))
|
||||
{
|
||||
gift.EquipmentPrefabName = prefab;
|
||||
gift.EquipmentModificationGuid = modGuid;
|
||||
}
|
||||
else if (StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc))
|
||||
{
|
||||
gift.ConsumableItemDesc = consumableDesc;
|
||||
}
|
||||
return gift;
|
||||
}
|
||||
|
||||
private static GiftPackageEntity BuildGiftFromItem(long pid, StoreItemEntity item, int giftContext, Random rng) =>
|
||||
BuildGiftFromItem(pid, new GiftPackageItem(item.Slug, item.DisplayName), giftContext, rng);
|
||||
|
||||
private readonly record struct GiftPackageItem(string Slug, string DisplayName);
|
||||
|
||||
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
|
||||
public Task<IActionResult> ConsumeViaPath(long id) => ConsumeImpl(id);
|
||||
|
||||
@@ -168,11 +233,30 @@ public class AvatarGiftsController(
|
||||
if (gift.RecipientPlayerId != pid) return Forbid();
|
||||
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
|
||||
|
||||
// Quest-reward chest: grant the exact backing store item (handles
|
||||
// avatar / equipment / consumable uniformly, idempotently). The
|
||||
// per-kind wire-derivation below is skipped for these — the wire
|
||||
// fields are still populated for the client to render the box.
|
||||
if (gift.SourceStoreItemId > 0)
|
||||
{
|
||||
await store.GrantItemAsync(pid, gift.SourceStoreItemId);
|
||||
gift.Consumed = true;
|
||||
gift.ConsumedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
// Append avatar item to player's inventory list (JSON list of
|
||||
// GUID strings). Defensive parse — corrupted JSON resets to
|
||||
// empty list rather than 500.
|
||||
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
|
||||
{
|
||||
// Inventory stores the BARE guid ("guid"), but a gift now carries
|
||||
// the 4-part wire desc ("guid,,,") so its post-consume unlock
|
||||
// matches api/avatar/v4/items. Normalise to the inventory form
|
||||
// here or we'd store "guid,,," alongside the bare "guid" a direct
|
||||
// purchase already added → a duplicate wardrobe row.
|
||||
var invDesc = StoreService.InventoryAvatarItemDesc(gift.AvatarItemDescOrHairDyeDesc);
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
{
|
||||
@@ -183,17 +267,16 @@ public class AvatarGiftsController(
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer
|
||||
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc))
|
||||
if (!list.Contains(invDesc))
|
||||
{
|
||||
list.Add(gift.AvatarItemDescOrHairDyeDesc);
|
||||
list.Add(invDesc);
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new[] { gift.AvatarItemDescOrHairDyeDesc });
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { invDesc });
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -253,17 +336,13 @@ public class AvatarGiftsController(
|
||||
gift.ConsumedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Profile-update push so the watch's wallet readout refreshes
|
||||
// without waiting for the next /balance poll. Mirrors what
|
||||
// /api/admin/v1/players/{id}/gift does after the inbox push so
|
||||
// the toast number animates up the moment the box closes.
|
||||
if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0)
|
||||
{
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level });
|
||||
}
|
||||
|
||||
// The wallet readout refreshes off the StorefrontBalanceUpdate that
|
||||
// GrantCurrencyAsync (above) already pushes for currency gifts. Do
|
||||
// NOT also push SubscriptionUpdateProfile ("AccountUpdate") with a
|
||||
// {Reason,Currency,…} blob: the 2023 client deserialises that channel
|
||||
// as its Account DTO, so a non-account payload decodes to a blank
|
||||
// account (accountId 0) → "Orphan player account (0) found" spam
|
||||
// (audited 2026-07-12).
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
@@ -288,14 +367,35 @@ public class AvatarGiftsController(
|
||||
// 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
|
||||
/// <summary>The desc the client matches against api/avatar/v4/items in
|
||||
/// OutfitManager.UnlockAvatarItemAndMarkNew after the gift box is
|
||||
/// consumed. That endpoint emits outfit items as the 4-part "{guid},,,"
|
||||
/// form (a bare guid crashes the client's AvatarItem parser), and the
|
||||
/// match is an EXACT string compare — so an outfit gift whose desc has
|
||||
/// fewer than 4 comma-parts (legacy rows stored the bare guid) makes
|
||||
/// Find() miss and NRE, which strips the item on consume. Pad outfit
|
||||
/// descs to 4 parts here so BOTH new and already-persisted gifts match.
|
||||
/// Hair dyes (AvatarItemType 1) match by bare colour guid — leave them;
|
||||
/// currency-only gifts have an empty desc.</summary>
|
||||
private static string NormalizeAvatarWireDesc(GiftPackageEntity g)
|
||||
{
|
||||
var d = g.AvatarItemDescOrHairDyeDesc ?? string.Empty;
|
||||
if (d.Length == 0 || g.AvatarItemType == 1) return d;
|
||||
var parts = d.Split(',').Length;
|
||||
return parts >= 4 ? d : d + new string(',', 4 - parts);
|
||||
}
|
||||
|
||||
public static object ToWire(GiftPackageEntity g)
|
||||
{
|
||||
var avatarDesc = NormalizeAvatarWireDesc(g);
|
||||
return new
|
||||
{
|
||||
g.Id,
|
||||
g.FromPlayerId,
|
||||
g.ConsumableItemDesc,
|
||||
g.AvatarItemType,
|
||||
AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc,
|
||||
g.AvatarItemDescOrHairDyeDesc,
|
||||
AvatarItemDesc = avatarDesc,
|
||||
AvatarItemDescOrHairDyeDesc = avatarDesc,
|
||||
g.EquipmentPrefabName,
|
||||
g.EquipmentModificationGuid,
|
||||
g.CurrencyType,
|
||||
@@ -330,5 +430,6 @@ public class AvatarGiftsController(
|
||||
g.ErrorMessage,
|
||||
g.SupportsCurrentPlatform,
|
||||
g.IsGifted,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
string? hairColor = null;
|
||||
string? skinColor = null;
|
||||
string? faceFeatures = null;
|
||||
string? outfitSelectionsV2 = null;
|
||||
string? customAvatarItems = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
@@ -91,6 +93,11 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
hairColor = ReadStringField(root, "HairColor");
|
||||
skinColor = ReadStringField(root, "SkinColor");
|
||||
faceFeatures = ReadStringField(root, "FaceFeatures");
|
||||
// 2023 client (HHDLNAPEMGP writer EKHJKDNHOPB) also
|
||||
// uploads OutfitSelectionsV2 + CustomAvatarItems —
|
||||
// persist them so remote-player fetches round-trip.
|
||||
outfitSelectionsV2 = ReadStringField(root, "OutfitSelectionsV2");
|
||||
customAvatarItems = ReadStringField(root, "CustomAvatarItems");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
@@ -116,6 +123,16 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
if (hairColor is not null) a.HairColor = hairColor;
|
||||
if (skinColor is not null) a.SkinColor = skinColor;
|
||||
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
|
||||
if (outfitSelectionsV2 is not null) a.OutfitSelectionsV2 = outfitSelectionsV2;
|
||||
if (customAvatarItems is not null)
|
||||
{
|
||||
// CustomAvatarItems arrives as a JSON ARRAY of
|
||||
// {"CustomAvatarItemId":guid,"BodyPart":byte}; ReadStringField
|
||||
// re-encodes arrays as raw JSON. Guard against non-array junk.
|
||||
a.CustomAvatarItemsJson = customAvatarItems.TrimStart().StartsWith('[')
|
||||
? customAvatarItems
|
||||
: "[]";
|
||||
}
|
||||
a.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToDto(a));
|
||||
@@ -140,6 +157,25 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/avatar/v2/{accountId}</c> — the 2023 client's
|
||||
/// remote-player avatar settings fetch. Decompile evidence
|
||||
/// (2023.03.21 ISIL, RecNet.Runtime FFFIMAGLKEG.txt:4845-5006,
|
||||
/// method KAFJNMDBKDB): URL is String.Format("{0}v2/{1}",
|
||||
/// "api/avatar/", accountId) — the account id is a bare path segment
|
||||
/// directly under v2 (NOT under /equipped/). The response is parsed
|
||||
/// as object HHDLNAPEMGP whose reader (EKHJKDNHOPB.txt:471-766)
|
||||
/// registers OutfitSelections / OutfitSelectionsV2 / FaceFeatures /
|
||||
/// SkinColor / HairColor / CustomAvatarItems. Without this route the
|
||||
/// request 404'd with an empty body → "Failed to load Rec Room
|
||||
/// remote player avatar settings" (Player.log:2597).</summary>
|
||||
[HttpGet("api/avatar/v2/{playerId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetSettingsFor(long playerId)
|
||||
{
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>Public lookup of another player's equipped avatar — the
|
||||
/// client uses this to render remote players' avatars in shared
|
||||
/// rooms. No auth requirement on read so anonymous calls in the
|
||||
@@ -174,7 +210,27 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
FaceFeatures = a?.FaceFeatures ?? string.Empty,
|
||||
HairColor = a?.HairColor ?? string.Empty,
|
||||
SkinColor = a?.SkinColor ?? string.Empty,
|
||||
OutfitSelectionsV2 = a?.OutfitSelectionsV2 ?? string.Empty,
|
||||
CustomAvatarItems = ParseCustomAvatarItems(a?.CustomAvatarItemsJson),
|
||||
};
|
||||
|
||||
/// <summary>Re-hydrate the stored CustomAvatarItems JSON into typed
|
||||
/// wire items so the response always carries a well-formed array
|
||||
/// (never a raw string). Bad/legacy blobs degrade to [].</summary>
|
||||
private static List<CustomAvatarItemRefDto> ParseCustomAvatarItems(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<CustomAvatarItemRefDto>>(
|
||||
json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class AvatarV2Dto
|
||||
@@ -190,4 +246,28 @@ public class AvatarV2Dto
|
||||
|
||||
[JsonPropertyName("FaceFeatures")]
|
||||
public string FaceFeatures { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>2023 client field — HHDLNAPEMGP reader key
|
||||
/// "OutfitSelectionsV2" (EKHJKDNHOPB.txt:498-514). Extra keys are
|
||||
/// skipped by the 2020 reader, so this is safe cross-version.</summary>
|
||||
[JsonPropertyName("OutfitSelectionsV2")]
|
||||
public string OutfitSelectionsV2 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>2023 client field — HHDLNAPEMGP reader key
|
||||
/// "CustomAvatarItems" (EKHJKDNHOPB.txt:594-610), a list of
|
||||
/// BGNNOMBFMLH items.</summary>
|
||||
[JsonPropertyName("CustomAvatarItems")]
|
||||
public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>BGNNOMBFMLH — { Guid CustomAvatarItemId, Byte BodyPart }
|
||||
/// (property types BGNNOMBFMLH.txt:3-39; JSON keys registered by reader
|
||||
/// MKAIABAJIAJ.txt:215-258).</summary>
|
||||
public class CustomAvatarItemRefDto
|
||||
{
|
||||
[JsonPropertyName("CustomAvatarItemId")]
|
||||
public Guid CustomAvatarItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("BodyPart")]
|
||||
public byte BodyPart { get; set; }
|
||||
}
|
||||
|
||||
@@ -62,7 +62,10 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
/// Anonymous calls return [].
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
|
||||
public class AvatarItemsController(
|
||||
DorkNetDbContext db,
|
||||
IConfiguration config,
|
||||
ServerSettingsService serverSettings) : ControllerBase
|
||||
{
|
||||
/// <summary>Master catalog keyed by GUID. Loaded once on first
|
||||
/// request from <c>data/avatar_item_lookup.json</c>. Same
|
||||
@@ -85,16 +88,26 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
|
||||
return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
// Defaults raised (30 → 100, 4 → 20) — the old 4-per-slot silently
|
||||
// dropped any newly-purchased item once a slot held 4+ entries (the
|
||||
// "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);
|
||||
// The old [1,120] / [1,20] ceilings reflected the 2020 *watch's*
|
||||
// render-budget headroom — feeding it too many BrowsableAvatarItem /
|
||||
// DynamicAvatarItemImposter rows could crash its wardrobe drawer.
|
||||
// This branch (march-2023-03-21) serves the 2023 DESKTOP client, which
|
||||
// has no such limit, and the watch-era cap was silently truncating the
|
||||
// owned wardrobe to 100 ("most of my items don't show"). Raise the
|
||||
// ceilings well above the full catalog (882 base items + variants) so
|
||||
// nothing the player owns is dropped; the config keys still let an
|
||||
// operator dial it back down for a watch build.
|
||||
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 4000), 1, 8000);
|
||||
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 1000), 1, 2000);
|
||||
|
||||
// "Everyone owns all avatar items" admin toggle (mirrors the
|
||||
// "everyone is friends" one): report the ENTIRE master catalog plus
|
||||
// all permanent hair dyes as owned, for every caller, without reading
|
||||
// or writing any inventory row. Flipping the toggle off instantly
|
||||
// reverts to the real per-player wardrobe below. Bounded by the same
|
||||
// maxItems ceiling so a future watch build can't be over-fed.
|
||||
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
|
||||
return Ok(BuildEntireCatalogOwned(maxItems));
|
||||
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
@@ -160,10 +173,13 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
{
|
||||
AvatarItemType = item.AvatarItemType,
|
||||
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
||||
AvatarItemId = item.AvatarItemId,
|
||||
PlatformMask = item.PlatformMask,
|
||||
FriendlyName = item.FriendlyName,
|
||||
Tooltip = item.Tooltip,
|
||||
Rarity = item.Rarity,
|
||||
TagList = item.TagList,
|
||||
IsBaseAvatarItem = item.IsBaseAvatarItem,
|
||||
});
|
||||
if (result.Count >= maxItems) break;
|
||||
}
|
||||
@@ -177,15 +193,96 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
{
|
||||
AvatarItemType = AvatarItemType.HairDye,
|
||||
AvatarItemDesc = hairColorGuid,
|
||||
AvatarItemId = StableAvatarItemId(hairColorGuid),
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = friendlyName,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
TagList = string.Empty,
|
||||
IsBaseAvatarItem = false,
|
||||
});
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The full owned set when the "everyone owns all avatar
|
||||
/// items" toggle is on: every catalog item (already carrying the
|
||||
/// "{guid},,," desc) plus every permanent hair dye, capped at
|
||||
/// <paramref name="maxItems"/>. Hair dyes are emitted in the same
|
||||
/// shape the per-player path uses (AvatarItemType.HairDye, desc =
|
||||
/// hair-color GUID) so the wardrobe renders them identically.</summary>
|
||||
private static List<UnlockedAvatarItemDto> BuildEntireCatalogOwned(int maxItems)
|
||||
{
|
||||
var catalog = _catalog.Value;
|
||||
var result = new List<UnlockedAvatarItemDto>(Math.Min(maxItems, catalog.Items.Count + 32));
|
||||
foreach (var item in catalog.Items.Values)
|
||||
{
|
||||
if (result.Count >= maxItems) return result;
|
||||
result.Add(item);
|
||||
}
|
||||
// Colored variants aren't in the base avatar_item_lookup catalog, so
|
||||
// without this the "everyone owns everything" wardrobe was missing all
|
||||
// ~1.8k colorways — the player didn't own them AND buying one gifted a
|
||||
// variant desc ("{guid},{combo},{mask},") that wasn't in this list, so
|
||||
// the client's post-consume UnlockAvatarItemAndMarkNew Find() missed
|
||||
// and NRE'd (the item vanished on buy). Emit each variant with its
|
||||
// verbatim equip descriptor and a UNIQUE id hashed from the FULL
|
||||
// descriptor (the base-guid hash would collide with the base item and
|
||||
// every sibling colorway).
|
||||
foreach (var v in StoreService.ColorVariantCatalog.Values)
|
||||
{
|
||||
if (result.Count >= maxItems) return result;
|
||||
if (string.IsNullOrWhiteSpace(v.descriptor)) continue;
|
||||
result.Add(new UnlockedAvatarItemDto
|
||||
{
|
||||
AvatarItemType = AvatarItemType.Outfit,
|
||||
AvatarItemDesc = v.descriptor,
|
||||
AvatarItemId = StableAvatarItemIdFull(v.descriptor),
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = v.item_name ?? string.Empty,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
TagList = string.Empty,
|
||||
IsBaseAvatarItem = false,
|
||||
});
|
||||
}
|
||||
foreach (var slug in StoreService.AllHairDyeSlugs)
|
||||
{
|
||||
if (result.Count >= maxItems) break;
|
||||
if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue;
|
||||
StoreService.TryGetHairDyeName(slug, out var friendlyName);
|
||||
result.Add(new UnlockedAvatarItemDto
|
||||
{
|
||||
AvatarItemType = AvatarItemType.HairDye,
|
||||
AvatarItemDesc = hairColorGuid,
|
||||
AvatarItemId = StableAvatarItemId(hairColorGuid),
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = friendlyName,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
TagList = string.Empty,
|
||||
IsBaseAvatarItem = false,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult DefaultBaseAvatarItems()
|
||||
{
|
||||
var rows = _catalog.Value.Items.Values
|
||||
.OrderBy(i => i.AvatarItemType)
|
||||
.ThenBy(i => i.FriendlyName)
|
||||
.Take(100)
|
||||
.ToList();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("api/avatar/v1/defaultunlocked")]
|
||||
[AllowAnonymous]
|
||||
public Task<ActionResult<List<UnlockedAvatarItemDto>>> DefaultUnlocked() => Get();
|
||||
|
||||
/// <summary>The set of GUIDs the bundled
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// contains. Exposed so other controllers (StoreService seed,
|
||||
@@ -213,6 +310,12 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
var requested = RequestedAvatarItemGuids().ToList();
|
||||
if (requested.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
// When everyone owns everything, nothing is "locked" — the store's
|
||||
// owned/locked diff must see an empty locked set so every tile reads
|
||||
// as owned, consistent with the full wardrobe returned by Get().
|
||||
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is long me)
|
||||
@@ -346,11 +449,17 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
/// 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);
|
||||
}
|
||||
// A stored colored descriptor is the EXACT RecNet equip string
|
||||
// ({itemGuid},{combinationGuid…}) — the client feeds everything after the
|
||||
// item GUID into combinationLookup.TryGetValue as an opaque, case-sensitive
|
||||
// key, so it must be returned byte-for-byte. Do NOT pad to 3 commas: the
|
||||
// 2023 combination model has short-form descriptors ({item},{combo}) whose
|
||||
// trailing commas are significant — padding "{item},{combo}" to
|
||||
// "{item},{combo},," corrupts the key and the watch fails to resolve the
|
||||
// combination (renders a broken/default mesh). Only genuinely bare entries
|
||||
// (FullDesc == BaseGuid) get the "{guid},,," base form, and those never
|
||||
// reach here (the caller emits the catalog DTO directly for them).
|
||||
private static string ToWatchDesc(string storedDesc) => storedDesc;
|
||||
|
||||
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
|
||||
{
|
||||
@@ -432,10 +541,13 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
// GUID as a color GUID and log "Can not find hair dye color".
|
||||
AvatarItemType = AvatarItemType.Outfit,
|
||||
AvatarItemDesc = row.Guid + ",,,",
|
||||
AvatarItemId = StableAvatarItemId(row.Guid),
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = pretty,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
TagList = string.Empty,
|
||||
IsBaseAvatarItem = false,
|
||||
};
|
||||
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
||||
bySlot[row.OutfitType] = lst = new List<string>();
|
||||
@@ -446,6 +558,33 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
return new CatalogData(items, safe, bySlot, slotByGuid);
|
||||
}
|
||||
|
||||
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned
|
||||
/// avatar item, derived from its base GUID (FNV-1a). The 2023 client
|
||||
/// keys the wardrobe by <c>AvatarItemId</c>, so it must be unique per
|
||||
/// item and stable across restarts. Colored variants reuse their base
|
||||
/// item's id (a player owns the base OR a variant, never both in the
|
||||
/// list), so no collision.</summary>
|
||||
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
|
||||
{
|
||||
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
|
||||
uint h = 2166136261u;
|
||||
foreach (var c in guid) h = (h ^ c) * 16777619u;
|
||||
var id = (int)(h & 0x7FFFFFFF);
|
||||
return id == 0 ? 1 : id;
|
||||
}
|
||||
|
||||
/// <summary>Stable id from the FULL descriptor string (not just the base
|
||||
/// guid). Colored variants share a base guid but are distinct wardrobe
|
||||
/// entries, so they must hash the whole "{guid},{combo},{mask}," string
|
||||
/// to get ids distinct from the base item and each other.</summary>
|
||||
public static int StableAvatarItemIdFull(string? descriptor)
|
||||
{
|
||||
uint h = 2166136261u;
|
||||
foreach (var c in descriptor ?? string.Empty) h = (h ^ c) * 16777619u;
|
||||
var id = (int)(h & 0x7FFFFFFF);
|
||||
return id == 0 ? 1 : id;
|
||||
}
|
||||
|
||||
private static string PrettyName(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return string.Empty;
|
||||
@@ -544,6 +683,16 @@ public class UnlockedAvatarItemDto
|
||||
[JsonPropertyName("AvatarItemDesc")]
|
||||
public string AvatarItemDesc { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The 2023 client's owned-item DTO (RecNet IFNGBPJGINL) reads
|
||||
/// <c>AvatarItemId</c> as an <b>Int32</b>. Omitting it left every owned
|
||||
/// item at id 0; the client keys its wardrobe by AvatarItemId, so a
|
||||
/// second item collided and the whole unlocked-items list failed to
|
||||
/// build — the wardrobe showed nothing owned and buying (which refetches
|
||||
/// this list) NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew. Emit a
|
||||
/// stable, unique, non-zero id.</summary>
|
||||
[JsonPropertyName("AvatarItemId")]
|
||||
public int AvatarItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("PlatformMask")]
|
||||
public int PlatformMask { get; set; }
|
||||
|
||||
@@ -555,4 +704,15 @@ public class UnlockedAvatarItemDto
|
||||
|
||||
[JsonPropertyName("Rarity")]
|
||||
public int Rarity { get; set; }
|
||||
|
||||
/// <summary>CSV tag string (IFNGBPJGINL.TagList). Empty is fine —
|
||||
/// AvatarItemMetadata.ParseTagList tolerates it — but the key must be
|
||||
/// present and a string, never null.</summary>
|
||||
[JsonPropertyName("TagList")]
|
||||
public string TagList { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this is a default base-body item vs a cosmetic
|
||||
/// (IFNGBPJGINL.IsBaseAvatarItem). False for wardrobe/catalog items.</summary>
|
||||
[JsonPropertyName("IsBaseAvatarItem")]
|
||||
public bool IsBaseAvatarItem { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Comments;
|
||||
|
||||
/// <summary>
|
||||
/// Room comments ("feedback"/"idea" notes players leave in a room, shown by
|
||||
/// the watch's RoomCommentsScreen / RoomCommentsPagedGrid).
|
||||
///
|
||||
/// Every route here is issued by RecNet.Runtime's room-comment service
|
||||
/// <c>OPELMBNJHNO</c> against service host <c>12 = RoomComments</c>
|
||||
/// (<see cref="Services.ConfigService"/> maps that to the <c>roomcomments</c>
|
||||
/// subdomain). The five literals, with the <c>BNDIAONDFFF</c> ctor verb
|
||||
/// ordinal moved into <c>rdx</c> immediately before the builder call
|
||||
/// (BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT, 4=DELETE):
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><c>OPELMBNJHNO.txt:485</c> "comments/get/{0}" — rdx=0 (GET), host [rdx+12]</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:811</c> "comments/unreadcounts" — dynamic GET/POST</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:1216</c> "comments/create/{0}" — rdx=2 (POST), host [r12+12]</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:1627</c> "comments/read/{0}/{1}" — rdx=3 (PUT), host [rbx+12]</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:1935</c> "comments/delete/{0}" — rdx=4 (DELETE), r8=12</item>
|
||||
/// </list>
|
||||
///
|
||||
/// STORAGE. There is no <c>RoomCommentEntity</c> and adding entities
|
||||
/// needs a migration, so comments live in the <see cref="PlayerSettingEntity"/>
|
||||
/// side-table exactly like the PlayerEvent extras
|
||||
/// (<c>PlayerEventsController.EventExtras</c>): one row per comment with
|
||||
/// <c>PlayerId</c> = the author, <c>Key = roomcomment:{roomId}:{commentId}</c>
|
||||
/// and <c>Value</c> = the JSON payload. Per-player read state is a second,
|
||||
/// disjoint namespace — <c>roomcommentread:{roomId}</c> holding the last-read
|
||||
/// comment id — which is what finally lets
|
||||
/// <see cref="UnreadCounts"/> report real numbers instead of zeroes.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class CommentsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
/// <summary>Key prefix for a stored comment: <c>roomcomment:{roomId}:{commentId}</c>.</summary>
|
||||
private const string CommentPrefix = "roomcomment:";
|
||||
|
||||
/// <summary>Key prefix for per-player read state: <c>roomcommentread:{roomId}</c>,
|
||||
/// value = last-read comment id. Deliberately a different prefix from
|
||||
/// <see cref="CommentPrefix"/> (not <c>roomcomment:read:…</c>) so the
|
||||
/// suffix match used by <see cref="Delete"/> can never hit a read-state row.</summary>
|
||||
private const string ReadPrefix = "roomcommentread:";
|
||||
|
||||
/// <summary>The <c>RecNet.RoomComment</c> members that are not already
|
||||
/// implied by the storage row's key/owner. <c>Position</c> is three separate
|
||||
/// nullable floats on the wire, not a Vector3: the client's
|
||||
/// <c>Nullable<Vector3></c> property is <c>[IgnoreDataMember]</c> and is
|
||||
/// rebuilt after deserialisation from three internal
|
||||
/// <c>Nullable<float></c> fields (RecNet/RoomComment.txt:495
|
||||
/// <c>FKDDCNLJOLF</c> reads the HasValue byte at <c>[rbx+0x6C]</c>, i.e. the
|
||||
/// first of the three at 0x68/0x70/0x78).
|
||||
///
|
||||
/// Public (not private) so System.Text.Json's reflection-based
|
||||
/// (de)serialiser can see the type and its accessors.</summary>
|
||||
public sealed class CommentPayload
|
||||
{
|
||||
public long? SubRoomId { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
/// <summary><c>RoomComment.EKHGDABMJOJ</c>: Feedback=0, Idea=1, BugReport=2
|
||||
/// (RecNet/RoomComment.cs enum member order).</summary>
|
||||
public int Style { get; set; }
|
||||
public float? X { get; set; }
|
||||
public float? Y { get; set; }
|
||||
public float? Z { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Comment list for a room.
|
||||
///
|
||||
/// <c>OPELMBNJHNO.GMBPJHCBPDP(Int64 roomId, Int32 count, Int64 minId)</c>
|
||||
/// returns <c>FGLDKEJLAKB<List<RecNet.RoomComment>></c>
|
||||
/// (OPELMBNJHNO.txt:341), so the body is a bare JSON <b>array</b> — not a
|
||||
/// paged <c>{Results,TotalResults}</c> container. Verb is GET (rdx=0 at
|
||||
/// instr 044) and both parameters go on the query string, because
|
||||
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> only promotes fields to a form body for
|
||||
/// non-GET verbs: literals "count" (:506) and "minId" (:518).
|
||||
///
|
||||
/// <c>minId</c> is a lower bound on the comment id (the only caller,
|
||||
/// FeedbackManager.txt:948, passes count=100 and a carried-forward id), so
|
||||
/// it is applied inclusively and the newest <c>count</c> matches are
|
||||
/// returned newest-first — which is the order the paged grid renders.
|
||||
///
|
||||
/// Failure toast if this 404s or the body will not parse:
|
||||
/// "Failed to get room comments for room: {0}" (:537).</summary>
|
||||
[HttpGet("/comments/get/{roomId:long}")]
|
||||
public async Task<IActionResult> Get(long roomId, [FromQuery] int count = 100, [FromQuery] long minId = 0)
|
||||
{
|
||||
if (count <= 0) count = 100;
|
||||
if (count > 500) count = 500;
|
||||
|
||||
var rows = await CommentRowsForRoomAsync(roomId);
|
||||
|
||||
var wire = rows
|
||||
.Where(r => r.CommentId >= minId)
|
||||
.OrderByDescending(r => r.CommentId)
|
||||
.Take(count)
|
||||
.Select(r => ToWire(r.CommentId, roomId, r.AuthorPlayerId, r.Payload, r.Unread))
|
||||
.ToList();
|
||||
|
||||
return Ok(wire);
|
||||
}
|
||||
|
||||
/// <summary>Room-comment unread counts. Client contract (RecNet.Runtime
|
||||
/// <c>OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection<long>)</c>).
|
||||
///
|
||||
/// The response is a JSON <b>ARRAY</b>, not an object. The method's return
|
||||
/// type is <c>Dictionary<long,uint></c>, but that dictionary is built
|
||||
/// client-side: OPELMBNJHNO.txt:889 constructs a
|
||||
/// <c>Func<List<UnreadRoomComments>, Dictionary<long,uint>></c>
|
||||
/// projection that runs over the deserialised payload. So the wire type is
|
||||
/// <c>List<RecNet.UnreadRoomComments></c> — each element an object with a
|
||||
/// room id (Int64) and a count (UInt32), per the two properties on
|
||||
/// RecNet/UnreadRoomComments.txt.
|
||||
///
|
||||
/// This previously returned an object map keyed by room id. Json.NET cannot
|
||||
/// read a JSON object into a <c>List<T></c>, so every call threw and the
|
||||
/// client logged "Failed to get unread room comment counts"
|
||||
/// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's
|
||||
/// room-comment unread badge never appeared.
|
||||
///
|
||||
/// The element's exact property names are attribute-driven and are not
|
||||
/// recoverable from the ISIL (see the note on key aliasing below), so each
|
||||
/// element carries the plausible spellings side by side. Json.NET matches
|
||||
/// case-insensitively and ignores unknown members, so the extra aliases are
|
||||
/// inert whichever name the DTO actually declares.
|
||||
///
|
||||
/// Counts are real now that <see cref="MarkRead"/> persists per-player read
|
||||
/// state: a room's count is the number of comments authored by somebody
|
||||
/// else whose id is above the caller's last-read id for that room.
|
||||
/// </summary>
|
||||
[HttpPost("/comments/unreadcounts")]
|
||||
[HttpGet("/comments/unreadcounts")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> UnreadCounts()
|
||||
{
|
||||
var roomIds = await ReadRoomIdsAsync();
|
||||
if (roomIds.Count == 0) return Ok(new List<Dictionary<string, object>>());
|
||||
|
||||
var me = Me;
|
||||
var lastRead = await LastReadAsync(me);
|
||||
|
||||
// Key + owner only: the payload column is irrelevant to counting.
|
||||
var keys = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(CommentPrefix))
|
||||
.Select(s => new { s.Key, s.PlayerId })
|
||||
.ToListAsync();
|
||||
|
||||
var counts = new Dictionary<long, uint>();
|
||||
foreach (var id in roomIds) counts[id] = 0u;
|
||||
|
||||
foreach (var k in keys)
|
||||
{
|
||||
if (!TrySplitCommentKey(k.Key, out var roomId, out var commentId)) continue;
|
||||
if (!counts.ContainsKey(roomId)) continue;
|
||||
if (k.PlayerId == me) continue; // your own notes are never unread
|
||||
if (commentId <= lastRead.GetValueOrDefault(roomId)) continue;
|
||||
counts[roomId]++;
|
||||
}
|
||||
|
||||
var result = roomIds
|
||||
.Select(id => new Dictionary<string, object>
|
||||
{
|
||||
["RoomId"] = id,
|
||||
["UnreadCount"] = counts[id],
|
||||
["Count"] = counts[id],
|
||||
})
|
||||
.ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Posts a new comment into a room.
|
||||
///
|
||||
/// <c>OPELMBNJHNO.JHABIKEMJHK(Int64 roomId, Int64 subRoomId, String message,
|
||||
/// RoomComment/EKHGDABMJOJ style, Nullable<Vector3> position)</c>
|
||||
/// (OPELMBNJHNO.txt:940) returns <c>FGLDKEJLAKB<RecNet.RoomComment></c> —
|
||||
/// a <b>single</b> comment object, not a list and not a status envelope.
|
||||
///
|
||||
/// Verb is POST (rdx=2 at instr 069), so every <c>AFGEDDANEKP</c> field is
|
||||
/// an <c>application/x-www-form-urlencoded</c> body field, never a query
|
||||
/// param. Field literals, in emission order: "message" (:1234),
|
||||
/// "subRoomId" (:1249, boxed Int64), "style" (:1261, boxed <b>UInt32</b>),
|
||||
/// then — only when the <c>Vector3?</c> has a value (the
|
||||
/// <c>Compare [rdi+12], r12 / JumpIfEqual</c> at :1268) — "positionX"
|
||||
/// (:1292), "positionY" (:1314), "positionZ" (:1336), each formatted with
|
||||
/// <c>Single.ToString(CultureInfo.InvariantCulture)</c> (:1285/1289), which
|
||||
/// is why the three floats are parsed invariantly here rather than left to
|
||||
/// model binding.
|
||||
///
|
||||
/// Failure toast: "Failed to Create Comment" (:1359).</summary>
|
||||
[HttpPost("/comments/create/{roomId:long}")]
|
||||
public async Task<IActionResult> Create(
|
||||
long roomId,
|
||||
[FromForm] string? message,
|
||||
[FromForm] long? subRoomId,
|
||||
[FromForm] int style = 0,
|
||||
[FromForm] string? positionX = null,
|
||||
[FromForm] string? positionY = null,
|
||||
[FromForm] string? positionZ = null)
|
||||
{
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId)) return NotFound();
|
||||
|
||||
message = (message ?? string.Empty).Trim();
|
||||
if (message.Length == 0) return BadRequest();
|
||||
// PlayerSettingEntity.Value is MaxLength(1024) and also carries the
|
||||
// rest of the payload; keep well clear of the column width.
|
||||
if (message.Length > 700) message = message[..700];
|
||||
|
||||
var payload = new CommentPayload
|
||||
{
|
||||
SubRoomId = subRoomId is > 0 ? subRoomId : null,
|
||||
Message = message,
|
||||
Style = style,
|
||||
X = ParseFloat(positionX),
|
||||
Y = ParseFloat(positionY),
|
||||
Z = ParseFloat(positionZ),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
// The client only ever sends all three or none; drop a partial set so
|
||||
// the client's Vector3 reassembly (RoomComment.FKDDCNLJOLF) sees a
|
||||
// consistent null.
|
||||
if (payload.X is null || payload.Y is null || payload.Z is null)
|
||||
payload.X = payload.Y = payload.Z = null;
|
||||
|
||||
var me = Me;
|
||||
var commentId = await NextCommentIdAsync();
|
||||
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = $"{CommentPrefix}{roomId}:{commentId}",
|
||||
Value = JsonSerializer.Serialize(payload),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Authoring a comment implies you have read up to it.
|
||||
await SetLastReadAsync(me, roomId, commentId);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(ToWire(commentId, roomId, me, payload, unread: false));
|
||||
}
|
||||
|
||||
/// <summary>Records the newest comment the caller has read in a room, which
|
||||
/// is what clears the watch's unread badge (and what makes
|
||||
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
|
||||
///
|
||||
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
|
||||
/// (OPELMBNJHNO.txt:1375) returns the status-only <c>LDGADANDBIO</c>, not a
|
||||
/// DTO — the body is never parsed, only the status code. Verb is <b>PUT</b>
|
||||
/// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments
|
||||
/// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so
|
||||
/// the request carries no body at all.
|
||||
///
|
||||
/// Failure toast: "Failed to update latest read comment" (:1656).</summary>
|
||||
[HttpPut("/comments/read/{roomId:long}/{commentId:long}")]
|
||||
public async Task<IActionResult> MarkRead(long roomId, long commentId)
|
||||
{
|
||||
await SetLastReadAsync(Me, roomId, commentId);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Deletes a comment.
|
||||
///
|
||||
/// <c>OPELMBNJHNO.NDLOEEHIDED(Int64 commentId)</c> (OPELMBNJHNO.txt:1781)
|
||||
/// returns the status-only <c>LDGADANDBIO</c>. Verb is <b>DELETE</b>
|
||||
/// (rdx=4 at instr 046) and the host ordinal is the only immediate on the
|
||||
/// builder call (r8=12, RoomComments) — see OPELMBNJHNO.txt:1944-1946.
|
||||
/// No fields are attached: the comment id is the whole request.
|
||||
///
|
||||
/// Because the route carries no room id, the storage key is matched by its
|
||||
/// <c>:{commentId}</c> suffix; read-state rows live under a different
|
||||
/// prefix precisely so they cannot collide here. Only the author, the room
|
||||
/// creator, or an accepted co-owner/moderator of the room may delete —
|
||||
/// anyone else gets 403 and the client raises its "Failed to delete
|
||||
/// comment" toast (:1962).</summary>
|
||||
[HttpDelete("/comments/delete/{commentId:long}")]
|
||||
public async Task<IActionResult> Delete(long commentId)
|
||||
{
|
||||
var suffix = ":" + commentId.ToString(CultureInfo.InvariantCulture);
|
||||
var row = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix))
|
||||
.FirstOrDefaultAsync();
|
||||
if (row is null) return NotFound();
|
||||
if (!TrySplitCommentKey(row.Key, out var roomId, out _)) return NotFound();
|
||||
|
||||
var me = Me;
|
||||
if (row.PlayerId != me && !await CanModerateRoomAsync(roomId, me)) return Forbid();
|
||||
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── Wire shape ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>One <c>RecNet.RoomComment</c>. Member list and CLR types are
|
||||
/// exact (RecNet/RoomComment.cs, cross-checked against the 2023.06.21
|
||||
/// dump.cs:1241576): <c>long</c> comment id, <c>long</c> room id,
|
||||
/// <c>long?</c> sub-room id, <c>int?</c> account id, <c>DateTime</c>
|
||||
/// created-at, <c>string</c> message, the <c>EKHGDABMJOJ</c> style enum, a
|
||||
/// settable <c>bool</c>, three internal <c>float?</c> position components,
|
||||
/// and one computed get-only <c>bool</c> that is never on the wire.
|
||||
///
|
||||
/// KEY NAMES. The 2023 DTOs' JSON names live in DataMember attribute blobs
|
||||
/// in global-metadata.dat and are not recoverable from Cpp2IL output, so
|
||||
/// the names below are taken from the client's own <b>request</b> field
|
||||
/// literals for the same values — "message", "subRoomId", "style",
|
||||
/// "positionX", "positionY", "positionZ" (OPELMBNJHNO.txt:1234-1336) —
|
||||
/// which mirror the server DTO they were serialised from. Json.NET matches
|
||||
/// case-insensitively and ignores unknown members, so the aliases for the
|
||||
/// two members with no request-side counterpart are inert.
|
||||
///
|
||||
/// The settable bool is the unread flag: its only mutator
|
||||
/// <c>RoomComment.MCCJIBCJEJC</c> clears the byte at <c>[rcx+0x64]</c> and
|
||||
/// fires the change event (RoomComment.txt:471-493), and the computed
|
||||
/// get-only bool <c>FOOFBPEMFCB</c> is <c>AccountId == localAccountId ||
|
||||
/// !flag</c> (:442-466, comparing against
|
||||
/// <c>OPMAPIOEIFG.GGAEMNHEHLO</c>). Its declared name is unknown, hence the
|
||||
/// three non-inverting spellings.</summary>
|
||||
private static Dictionary<string, object?> ToWire(
|
||||
long commentId, long roomId, long authorPlayerId, CommentPayload p, bool unread) => new(StringComparer.Ordinal)
|
||||
{
|
||||
["CommentId"] = commentId,
|
||||
["RoomId"] = roomId,
|
||||
["SubRoomId"] = p.SubRoomId,
|
||||
["AccountId"] = (int)authorPlayerId,
|
||||
["CreatedAt"] = p.CreatedAt,
|
||||
["Message"] = p.Message,
|
||||
["Style"] = p.Style,
|
||||
["PositionX"] = p.X,
|
||||
["PositionY"] = p.Y,
|
||||
["PositionZ"] = p.Z,
|
||||
["IsUnread"] = unread,
|
||||
["Unread"] = unread,
|
||||
["IsNew"] = unread,
|
||||
};
|
||||
|
||||
// ── Storage helpers ──────────────────────────────────────────────────
|
||||
|
||||
private sealed record StoredComment(long CommentId, long AuthorPlayerId, CommentPayload Payload, bool Unread);
|
||||
|
||||
/// <summary>Every stored comment for a room, with the caller's unread flag
|
||||
/// resolved against their read-state row.</summary>
|
||||
private async Task<List<StoredComment>> CommentRowsForRoomAsync(long roomId)
|
||||
{
|
||||
var me = Me;
|
||||
var prefix = $"{CommentPrefix}{roomId}:";
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(prefix))
|
||||
.Select(s => new { s.Key, s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
|
||||
var lastRead = await LastReadAsync(me);
|
||||
var seen = lastRead.GetValueOrDefault(roomId);
|
||||
|
||||
var list = new List<StoredComment>(rows.Count);
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (!long.TryParse(r.Key[prefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId))
|
||||
continue;
|
||||
CommentPayload? payload;
|
||||
try { payload = JsonSerializer.Deserialize<CommentPayload>(r.Value); }
|
||||
catch (JsonException) { continue; }
|
||||
if (payload is null) continue;
|
||||
list.Add(new StoredComment(commentId, r.PlayerId, payload, r.PlayerId != me && commentId > seen));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Comment ids are handed out from the wall clock in milliseconds
|
||||
/// so they stay globally unique and monotonically increasing across
|
||||
/// restarts — the client's <c>minId</c> filter and the unread comparison
|
||||
/// both rely on "higher id == newer".</summary>
|
||||
private async Task<long> NextCommentIdAsync()
|
||||
{
|
||||
var id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
for (var attempt = 0; attempt < 64; attempt++)
|
||||
{
|
||||
var suffix = ":" + id.ToString(CultureInfo.InvariantCulture);
|
||||
var taken = await db.PlayerSettings
|
||||
.AnyAsync(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix));
|
||||
if (!taken) break;
|
||||
id++;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Last-read comment id per room for one player.</summary>
|
||||
private async Task<Dictionary<long, long>> LastReadAsync(long playerId)
|
||||
{
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == playerId && s.Key.StartsWith(ReadPrefix))
|
||||
.Select(s => new { s.Key, s.Value })
|
||||
.ToListAsync();
|
||||
|
||||
var map = new Dictionary<long, long>();
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (!long.TryParse(r.Key[ReadPrefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var roomId)) continue;
|
||||
if (!long.TryParse(r.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId)) continue;
|
||||
if (!map.TryGetValue(roomId, out var cur) || commentId > cur) map[roomId] = commentId;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>Read state only ever moves forward — the watch re-sends older
|
||||
/// ids as the grid scrolls back up.</summary>
|
||||
private async Task SetLastReadAsync(long playerId, long roomId, long commentId)
|
||||
{
|
||||
var key = $"{ReadPrefix}{roomId}";
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = key,
|
||||
Value = commentId.ToString(CultureInfo.InvariantCulture),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (long.TryParse(row.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var cur) && cur >= commentId) return;
|
||||
row.Value = commentId.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>Room creator, or an accepted co-owner (Role 0) / moderator
|
||||
/// (Role 1) — the same grant model
|
||||
/// <see cref="Data.Entities.RoomRoleEntity"/> documents.</summary>
|
||||
private async Task<bool> CanModerateRoomAsync(long roomId, long playerId)
|
||||
{
|
||||
if (await db.Rooms.AnyAsync(r => r.Id == roomId && r.CreatorPlayerId == playerId)) return true;
|
||||
return await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && (r.Role == 0 || r.Role == 1));
|
||||
}
|
||||
|
||||
/// <summary>Splits <c>roomcomment:{roomId}:{commentId}</c>.</summary>
|
||||
private static bool TrySplitCommentKey(string key, out long roomId, out long commentId)
|
||||
{
|
||||
roomId = 0;
|
||||
commentId = 0;
|
||||
if (!key.StartsWith(CommentPrefix, StringComparison.Ordinal)) return false;
|
||||
var rest = key[CommentPrefix.Length..];
|
||||
var sep = rest.IndexOf(':');
|
||||
if (sep <= 0) return false;
|
||||
return long.TryParse(rest[..sep], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)
|
||||
&& long.TryParse(rest[(sep + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out commentId);
|
||||
}
|
||||
|
||||
private static float? ParseFloat(string? s) =>
|
||||
float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||
|
||||
private async Task<HashSet<long>> ReadRoomIdsAsync()
|
||||
{
|
||||
var ids = new HashSet<long>();
|
||||
|
||||
void AddCsv(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return;
|
||||
foreach (var part in s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
|
||||
// Query / form: roomIds=1,2,3
|
||||
foreach (var k in new[] { "roomIds", "roomId", "ids", "id" })
|
||||
{
|
||||
AddCsv(Request.Query[k].ToString());
|
||||
if (Request.HasFormContentType) AddCsv(Request.Form[k].ToString());
|
||||
}
|
||||
|
||||
// JSON body: either a bare array [1,2,3] or { "roomIds": [1,2,3] }.
|
||||
if (!Request.HasFormContentType)
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
JsonElement arr = default;
|
||||
var haveArr = false;
|
||||
if (root.ValueKind == JsonValueKind.Array) { arr = root; haveArr = true; }
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var k in new[] { "roomIds", "RoomIds", "ids", "Ids" })
|
||||
if (root.TryGetProperty(k, out arr) && arr.ValueKind == JsonValueKind.Array) { haveArr = true; break; }
|
||||
}
|
||||
if (haveArr)
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
catch { /* non-JSON / empty body */ }
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,6 @@ public class CommunityBoardController(
|
||||
? new()
|
||||
: await db.Rooms
|
||||
.Where(r => ids.Contains(r.Id))
|
||||
.Select(r => new { r.Id, r.Name, r.ImageName })
|
||||
.ToDictionaryAsync(r => r.Id);
|
||||
// 2020.12 FeaturedRoomGroup deserializer (NMPFCIJPODA.PPGFHEDFBEA)
|
||||
// reads required keys: FeaturedRoomGroupId, Name, Rooms (NOT
|
||||
@@ -76,13 +75,13 @@ public class CommunityBoardController(
|
||||
FeaturedRoomGroupId = 0L,
|
||||
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 })
|
||||
? new { RoomName = r.Name, RoomId = r.Id, ImageName = RoomService.ResolveDisplayImageName(r) }
|
||||
: new { RoomName = string.Empty, RoomId = id, ImageName = RoomService.DefaultRoomImageName })
|
||||
.ToArray(),
|
||||
// Keep the old key around for any 2020.03 clients hitting prod.
|
||||
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
|
||||
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
|
||||
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
|
||||
? new { RoomName = r.Name, RoomId = r.Id, ImageName = RoomService.ResolveDisplayImageName(r) }
|
||||
: new { RoomName = string.Empty, RoomId = id, ImageName = RoomService.DefaultRoomImageName })
|
||||
.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
@@ -12,6 +13,19 @@ namespace DorkNet.Server.Controllers.API.Compatibility;
|
||||
[ApiController]
|
||||
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>The 2023-03-21 client GETs this (verb register <c>rdx=0</c>
|
||||
/// at RecNet.Runtime/FPIBGPIAOBI.txt:2984, immediately before the
|
||||
/// <c>"api/banappeal/generateCode"</c> literal on :2980) and deserialises
|
||||
/// the response as a bare <c>String</c> — the issuing method is
|
||||
/// <c>FGLDKEJLAKB<System.String> HBEPHMAKCDN()</c> (:2759) and the
|
||||
/// continuation is <c>Func<IPCJLCNIBEG<String>,
|
||||
/// FGLDKEJLAKB<String>></c> (:3019).
|
||||
///
|
||||
/// It used to be POST-only returning <c>{value: code}</c>, so the client
|
||||
/// got a 405 and, had it reached the handler, would have thrown parsing a
|
||||
/// JSON object into a string. Ban-appeal code generation never worked.
|
||||
/// The body is now the bare JSON string (<c>"BA-123456"</c>).</summary>
|
||||
[HttpGet("api/banappeal/generateCode")]
|
||||
[HttpPost("api/banappeal/generateCode")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GenerateBanAppealCode()
|
||||
@@ -25,7 +39,7 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
Value = code,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { value = code });
|
||||
return Content(JsonSerializer.Serialize(code), "application/json");
|
||||
}
|
||||
|
||||
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
|
||||
@@ -60,19 +74,145 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
.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
|
||||
var active = targetId == pid || await db.Subscriptions.AnyAsync(s =>
|
||||
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
|
||||
var now = DateTime.UtcNow;
|
||||
var subscriptionId = unchecked((pid * 397L) ^ targetId);
|
||||
var subscription = new Dictionary<string, object?>
|
||||
{
|
||||
subscription = (object?)null,
|
||||
platformAccountSubscribedPlayerId = targetId,
|
||||
["subscriptionId"] = subscriptionId,
|
||||
["recNetPlayerId"] = pid,
|
||||
["platformAccountSubscribedPlayerId"] = targetId,
|
||||
["isActive"] = active,
|
||||
["startedAt"] = now,
|
||||
["currentPeriodEnd"] = active ? now.AddYears(10) : now,
|
||||
["renewalDate"] = active ? now.AddYears(10) : now,
|
||||
["SubscriptionId"] = subscriptionId,
|
||||
["RecNetPlayerId"] = pid,
|
||||
["PlatformAccountSubscribedPlayerId"] = targetId,
|
||||
["IsActive"] = active,
|
||||
["StartedAt"] = now,
|
||||
["CurrentPeriodEnd"] = active ? now.AddYears(10) : now,
|
||||
["RenewalDate"] = active ? now.AddYears(10) : now,
|
||||
};
|
||||
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["subscription"] = subscription,
|
||||
["platformAccountSubscribedPlayerId"] = targetId,
|
||||
["Subscription"] = subscription,
|
||||
["PlatformAccountSubscribedPlayerId"] = targetId,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
|
||||
=> Content("true", "application/json");
|
||||
|
||||
/// <summary>The 2023-03-21 client POSTs this — the request is built at
|
||||
/// RecNet.Runtime/GAAPALMODOL.txt:104-106, where the route literal
|
||||
/// <c>"api/AppIntegrity/v1/iosproducts"</c> goes into <c>r9</c> and the
|
||||
/// verb register <c>rdx=2</c> = <c>BestHTTP.HTTPMethods.Post</c> (host
|
||||
/// <c>r8=1</c>). The payload is a RAW JSON body — the serialized list is
|
||||
/// handed to <c>BNDIAONDFFF.FJLLPHFOOJJ</c> (:138), not the
|
||||
/// form-parameter helper — holding
|
||||
/// <c>List<GAAPALMODOL/KOLPHINEHDD></c>, i.e. a top-level array of
|
||||
/// {Name:String, Price:Single, ProductId:String} (getters at
|
||||
/// GAAPALMODOL_NestedType_KOLPHINEHDD.txt:3/25/45; formatter key table,
|
||||
/// which also accepts camel/lower spellings, at KAONMCEONPF.txt:255-306).
|
||||
/// The issuing method's return type is
|
||||
/// <c>FGLDKEJLAKB<PHMHCPEMABG></c> (:3) and PHMHCPEMABG is
|
||||
/// {Boolean, String} (PHMHCPEMABG.txt:3/23) keyed
|
||||
/// <c>Success</c>/<c>Message</c> (GBPDOLJBABB.txt:191-218).
|
||||
///
|
||||
/// <see cref="DorkNet.Server.Controllers.API.AppIntegrity.AppIntegrityController"/>
|
||||
/// registers the same
|
||||
/// path GET-only, so the client's POST hit a 405 and the StoreKit
|
||||
/// price report was dropped. Registering only the POST verb here leaves
|
||||
/// that GET (and its consumers) untouched — the two never collide
|
||||
/// because the method constraints are disjoint.
|
||||
///
|
||||
/// The reported catalogue is what Apple's StoreKit resolved on the
|
||||
/// device, so it is inherently per-account/per-storefront: it is
|
||||
/// persisted as the reporting player's <c>appintegrity:iosproducts</c>
|
||||
/// setting (same PlayerSettings convention as the ban-appeal handler
|
||||
/// above), replacing any earlier report. Only a malformed body answers
|
||||
/// Success=false; the client shows nothing on success and PHMHCPEMABG's
|
||||
/// Message is left empty.</summary>
|
||||
[HttpPost("api/AppIntegrity/v1/iosproducts")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IosProducts()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
|
||||
List<IosProduct>? products;
|
||||
try
|
||||
{
|
||||
products = await JsonSerializer.DeserializeAsync<List<IosProduct>>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return IosProductsResult(false, "malformed_product_list");
|
||||
}
|
||||
|
||||
if (products is null) return IosProductsResult(false, "malformed_product_list");
|
||||
|
||||
// "{productId}={price}" pairs rather than the raw JSON: the
|
||||
// localized Name adds nothing a server-side reader needs and
|
||||
// PlayerSettingEntity.Value caps at 1024 chars, so entries are
|
||||
// dropped (never truncated mid-pair) once the budget is spent.
|
||||
var parts = new List<string>();
|
||||
var length = 0;
|
||||
foreach (var p in products)
|
||||
{
|
||||
var productId = p.ProductId?.Trim();
|
||||
if (string.IsNullOrEmpty(productId)) continue;
|
||||
var pair = $"{productId}={p.Price.ToString(CultureInfo.InvariantCulture)}";
|
||||
var cost = parts.Count == 0 ? pair.Length : pair.Length + 1;
|
||||
if (length + cost > 1024) break;
|
||||
parts.Add(pair);
|
||||
length += cost;
|
||||
}
|
||||
|
||||
const string key = "appintegrity:iosproducts";
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = key,
|
||||
Value = string.Join(';', parts),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = string.Join(';', parts);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return IosProductsResult(true, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>PHMHCPEMABG accepts PascalCase, camelCase and all-lowercase
|
||||
/// key spellings (GBPDOLJBABB.txt:191-218), so either casing alone would
|
||||
/// satisfy it; both are emitted for the same reason the CampusCard
|
||||
/// payload above does. Dictionary keys go out verbatim — the global
|
||||
/// serializer sets PropertyNamingPolicy = null
|
||||
/// (Startup/ServiceCollectionExtensions.cs:381).</summary>
|
||||
private IActionResult IosProductsResult(bool success, string message)
|
||||
=> Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["Success"] = success,
|
||||
["Message"] = message,
|
||||
["success"] = success,
|
||||
["message"] = message,
|
||||
});
|
||||
|
||||
[HttpPost("api/clubreporting/v1/report")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
@@ -85,7 +225,9 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
: null;
|
||||
if (club is null) return Ok(new { Success = false, Message = "club_not_found" });
|
||||
|
||||
var message = $"Club report: {req.Message ?? string.Empty}".Trim();
|
||||
// Prefix with "[club {id}]" so admins can tell club reports apart
|
||||
// from player reports in the shared ReportEntity queue.
|
||||
var message = $"[club {club.Id}] {req.Details ?? req.Message ?? string.Empty}".Trim();
|
||||
if (message.Length > 1000) message = message[..1000];
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
@@ -130,7 +272,13 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
?? 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(),
|
||||
// The 2023 client names the free-text field "details"
|
||||
// (IKMMOCKDKAF.txt:25303-25322), not "message" — reading only
|
||||
// the latter stored every club report with an empty body.
|
||||
Message = form["details"].FirstOrDefault()
|
||||
?? form["Details"].FirstOrDefault()
|
||||
?? form["message"].FirstOrDefault()
|
||||
?? form["Message"].FirstOrDefault(),
|
||||
};
|
||||
}
|
||||
try
|
||||
@@ -148,10 +296,24 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
public long PlatformAccountSubscribedPlayerId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One entry of the client's raw JSON array —
|
||||
/// GAAPALMODOL/KOLPHINEHDD, whose three properties are String, Single,
|
||||
/// String in that field order (offsets 0x10/0x18/0x20 in
|
||||
/// GAAPALMODOL_NestedType_KOLPHINEHDD.txt) and whose formatter writes
|
||||
/// the keys Name / Price / ProductId (KAONMCEONPF.txt:321-360).</summary>
|
||||
private sealed class IosProduct
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public float Price { get; set; }
|
||||
public string? ProductId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ClubReportRequest
|
||||
{
|
||||
public long ClubId { get; set; }
|
||||
public int ReportCategory { get; set; } = 5;
|
||||
public string? Message { get; set; }
|
||||
/// <summary>The client's spelling of <see cref="Message"/>.</summary>
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,123 @@ public class ConfigController(ConfigService configService) : ControllerBase
|
||||
[HttpGet("amplitude")]
|
||||
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>api/config/v1/backtrace</c> — 2023 boot downloads this before
|
||||
/// audio/core initialization. Returning 404 leaves the static backtrace
|
||||
/// config null and later crashes <c>ConfigSettings</c> consumers. Keep
|
||||
/// crash upload/deobfuscation disabled locally while preserving the
|
||||
/// object shape and readable config keys the client inspects.
|
||||
/// </summary>
|
||||
[HttpGet("backtrace")]
|
||||
public ActionResult<object> GetBacktrace(
|
||||
[FromQuery(Name = "platformType")] string? platformType,
|
||||
[FromQuery(Name = "allocate")] bool? allocate)
|
||||
{
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
var submissionUrl = $"{baseUrl}/backtrace/submit";
|
||||
var minidumpSubmissionUrl = $"{baseUrl}/backtrace/minidump";
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["Backtrace.SampleRate"] = 0.0,
|
||||
["Backtrace.ReportBudget"] = 0,
|
||||
["Backtrace.deobfuscationRate"] = 0.0,
|
||||
["Backtrace.Deobfuscate"] = false,
|
||||
["Backtrace.DeobfuscationUrl"] = string.Empty,
|
||||
["Backtrace.Enabled"] = false,
|
||||
["Backtrace.PlatformType"] = platformType ?? string.Empty,
|
||||
["Backtrace.Allocate"] = allocate ?? false,
|
||||
};
|
||||
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["MaxMessagesPerMinute"] = 0,
|
||||
["MaxReportsPerMinute"] = 0,
|
||||
["SampleRate"] = 0.0,
|
||||
["ReportBudget"] = 0,
|
||||
["ErrorReportBudget"] = 0,
|
||||
["WarningReportBudget"] = 0,
|
||||
["LogReportBudget"] = 0,
|
||||
["SubmissionUrl"] = submissionUrl,
|
||||
["MinidumpSubmissionUrl"] = minidumpSubmissionUrl,
|
||||
["ServerUrl"] = submissionUrl,
|
||||
["DatabasePath"] = "backtrace/database",
|
||||
["Config"] = config,
|
||||
["maxMessagesPerMinute"] = 0,
|
||||
["maxReportsPerMinute"] = 0,
|
||||
["sampleRate"] = 0.0,
|
||||
["reportBudget"] = 0,
|
||||
["errorReportBudget"] = 0,
|
||||
["warningReportBudget"] = 0,
|
||||
["logReportBudget"] = 0,
|
||||
["submissionUrl"] = submissionUrl,
|
||||
["minidumpSubmissionUrl"] = minidumpSubmissionUrl,
|
||||
["serverUrl"] = submissionUrl,
|
||||
["databasePath"] = "backtrace/database",
|
||||
["config"] = config,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("azurespeech")]
|
||||
public IActionResult GetAzureSpeech()
|
||||
{
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["Enabled"] = false,
|
||||
["SpeechRecognitionEnabled"] = false,
|
||||
["SpeechSynthesisEnabled"] = false,
|
||||
["Region"] = string.Empty,
|
||||
["SubscriptionKey"] = string.Empty,
|
||||
["TokenEndpoint"] = string.Empty,
|
||||
["SpeechRecognitionEndpoint"] = string.Empty,
|
||||
["SpeechSynthesisEndpoint"] = string.Empty,
|
||||
["enabled"] = false,
|
||||
["speechRecognitionEnabled"] = false,
|
||||
["speechSynthesisEnabled"] = false,
|
||||
["region"] = string.Empty,
|
||||
["subscriptionKey"] = string.Empty,
|
||||
["tokenEndpoint"] = string.Empty,
|
||||
["speechRecognitionEndpoint"] = string.Empty,
|
||||
["speechSynthesisEndpoint"] = string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/voice/config")]
|
||||
public IActionResult GetVoiceConfig()
|
||||
{
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["ToxMod.Enabled"] = false,
|
||||
["ToxMod.SampleRate"] = 0.0,
|
||||
["ToxMod.SessionRecordingEnabled"] = false,
|
||||
["ToxMod.Endpoint"] = string.Empty,
|
||||
["ToxMod.ApiKey"] = string.Empty,
|
||||
["Enabled"] = false,
|
||||
["enabled"] = false,
|
||||
["toxModEnabled"] = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/voice/requiresModeration</c> — bare Boolean the
|
||||
/// client reads to decide whether voice needs moderation. ToxMod is
|
||||
/// disabled server-side, so no moderation is required.</summary>
|
||||
[HttpGet("/voice/requiresModeration")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult VoiceRequiresModeration() => Content("false", "application/json");
|
||||
|
||||
/// <summary>GET <c>/emojiConfig/whitelistedEmojis</c> — the allow-list of
|
||||
/// emoji permitted in text chat (RRUI.EmojiPicker, IReadOnlyList<string>).
|
||||
/// Returns a broad default set so the picker is populated.</summary>
|
||||
[HttpGet("/emojiConfig/whitelistedEmojis")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult WhitelistedEmojis() => Ok(new[]
|
||||
{
|
||||
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
|
||||
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
|
||||
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
|
||||
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
|
||||
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
|
||||
});
|
||||
|
||||
/// <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
|
||||
|
||||
@@ -15,5 +15,5 @@ public class ServiceUrlsController(DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet("services")]
|
||||
public ActionResult<Dictionary<string, string>> Get()
|
||||
=> Ok(ConfigService.BuildServiceUrlMap(domain.Apex));
|
||||
=> Ok(ConfigService.BuildServiceUrlMap(domain));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using DorkNet.Server.Services;
|
||||
namespace DorkNet.Server.Controllers.API.Config.V2;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/config")]
|
||||
[Route("api/[controller]/v2")]
|
||||
public class ConfigController(ConfigService configService) : ControllerBase
|
||||
{
|
||||
|
||||
@@ -0,0 +1,1229 @@
|
||||
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;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/customAvatarItems")]
|
||||
[Route("api/customAvatarItems/v1")]
|
||||
[Route("api/customAvatarItems/v2")]
|
||||
public class CustomAvatarItemsController(
|
||||
DorkNetDbContext db,
|
||||
LevelService level,
|
||||
DomainConfig domain,
|
||||
IObjectStorage storage,
|
||||
ILogger<CustomAvatarItemsController> logger) : ControllerBase
|
||||
{
|
||||
/// <summary>Floor price for a publicly listed custom item. The client
|
||||
/// asks for this over the wire (<c>api/customAvatarItems/v1/minPriceForPublicItem</c>,
|
||||
/// RecNet.Runtime/MPBLNLMCEDL.txt:6893) and then enforces it locally in the
|
||||
/// designer, so the number the create/update paths clamp to and the number
|
||||
/// served on that route have to be the same constant or the client's own
|
||||
/// validation disagrees with the server's rejection.</summary>
|
||||
private const int MinPublicItemPrice = 100;
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(0, skip);
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
IQueryable<CustomAvatarItemEntity> rows = db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
rows = rows.Where(i =>
|
||||
i.Name.ToLower().Contains(needle) ||
|
||||
i.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var result = await rows
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(result.Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>POST api/customAvatarItems/v1 — publish a shirt from the
|
||||
/// in-game designer. The collection route was GET-only, so every
|
||||
/// create 405'd and the designer's "publish" button could never work.
|
||||
///
|
||||
/// The 2023-03-21 client sends <c>multipart/form-data</c> (verb ordinal
|
||||
/// rdx=2=POST at RecNet.Runtime/MPBLNLMCEDL.txt:1352-1354): a named
|
||||
/// param <c>"metadata"</c> carrying JsonConvert.SerializeObject of the
|
||||
/// DCKDCBDHDKI request object, then TWO file parts, <c>"thumbnailImage"</c>
|
||||
/// and <c>"design"</c>, both with filename <c>"file.bin"</c>
|
||||
/// (:1362-1390). DCKDCBDHDKI's field order is pinned by the arg-store
|
||||
/// sequence at :1096-1133 — +0x10 Name, +0x18 Description, +0x20 Price,
|
||||
/// +0x24 BaseAvatarItemId(int?), +0x30 Color ("#RRGGBB", built via
|
||||
/// ColorUtility.ToHtmlStringRGB at :1118-1131), +0x38 the permission
|
||||
/// enum we store as ItemType. The metadata JSON KEY names live in
|
||||
/// attribute metadata the dump doesn't render, so every field is read
|
||||
/// through an alias list rather than a single guessed key.</summary>
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
[Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadDesignRequestAsync();
|
||||
|
||||
var name = (req.Name ?? string.Empty).Trim();
|
||||
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
|
||||
if (name.Length > 128) name = name[..128];
|
||||
var description = (req.Description ?? string.Empty).Trim();
|
||||
if (description.Length > 1024) description = description[..1024];
|
||||
|
||||
var item = new CustomAvatarItemEntity
|
||||
{
|
||||
CreatorPlayerId = pid,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Price = Math.Max(MinPublicItemPrice, req.Price ?? MinPublicItemPrice),
|
||||
ItemType = req.ItemType ?? 0,
|
||||
BaseAvatarItemId = req.BaseAvatarItemId ?? 0,
|
||||
Color = Trim(req.Color, 64),
|
||||
// No boolean lives in DCKDCBDHDKI, so a freshly created item is
|
||||
// private until the permission enum (ItemType) says otherwise.
|
||||
IsPublic = req.IsPublic ?? false,
|
||||
};
|
||||
|
||||
// "thumbnailImage" → ImageName (card art), "design" → AssetName (the
|
||||
// shirt texture itself). Fall back to positional order for any client
|
||||
// that names the parts differently.
|
||||
var (thumbnail, asset) = ReadItemFileParts();
|
||||
if (thumbnail is not null)
|
||||
item.ImageName = await StoreImageBlobAsync(pid, thumbnail, "custom item thumbnail");
|
||||
if (asset is not null)
|
||||
item.AssetName = await StoreImageBlobAsync(pid, asset, "custom item design");
|
||||
if (item.ImageName.Length == 0) item.ImageName = Trim(req.ImageName, 256);
|
||||
if (item.AssetName.Length == 0) item.AssetName = Trim(req.AssetName, 256);
|
||||
|
||||
db.CustomAvatarItems.Add(item);
|
||||
await db.SaveChangesAsync();
|
||||
await EnsureOwnershipAsync(pid, item.Id);
|
||||
|
||||
logger.LogInformation(
|
||||
"[customAvatarItems] created {PublicId} by {Pid} name='{Name}' image={Image} asset={Asset}",
|
||||
item.PublicId, pid, item.Name, item.ImageName, item.AssetName);
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
||||
if (item is null) return NotFound();
|
||||
if (!item.IsPublic && item.CreatorPlayerId != this.CurrentPlayerId()) return Forbid();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
/// <summary>PUT api/customAvatarItems/v1/{id} — edit an existing item's
|
||||
/// metadata. Only GET was registered on the id route, so the details
|
||||
/// screen's Save button 405'd ("Unable to update custom avatar item").
|
||||
///
|
||||
/// The client builds the URL with String.Format("{0}/v1/{1}") over
|
||||
/// "api/customAvatarItems" and issues verb ordinal 3 = PUT
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:1899-1913). The body is RAW JSON —
|
||||
/// JsonConvert.SerializeObject of MOBHOFPIIIJ handed to
|
||||
/// BNDIAONDFFF.FJLLPHFOOJJ at :1890-1920 — NOT form-urlencoded, so this
|
||||
/// reads the body itself rather than model-binding it. MOBHOFPIIIJ
|
||||
/// carries exactly four properties whose accessors are shared with
|
||||
/// NOAAFNFJPFB (MOBHOFPIIIJ.txt:3-77): Name, Description, Price(int?)
|
||||
/// and the permission enum stored here as ItemType. Every field is
|
||||
/// optional — absent members leave the stored value alone.</summary>
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Update(Guid id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
||||
if (item is null) return NotFound();
|
||||
if (item.CreatorPlayerId != pid) return Forbid();
|
||||
|
||||
var req = await ReadDesignRequestAsync();
|
||||
if (req.Name is { } rawName)
|
||||
{
|
||||
var name = rawName.Trim();
|
||||
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
|
||||
item.Name = name.Length > 128 ? name[..128] : name;
|
||||
}
|
||||
if (req.Description is { } rawDescription)
|
||||
{
|
||||
var description = rawDescription.Trim();
|
||||
item.Description = description.Length > 1024 ? description[..1024] : description;
|
||||
}
|
||||
if (req.Price is { } price) item.Price = Math.Max(MinPublicItemPrice, price);
|
||||
if (req.ItemType is { } itemType) item.ItemType = itemType;
|
||||
if (req.BaseAvatarItemId is { } baseItemId) item.BaseAvatarItemId = baseItemId;
|
||||
if (req.Color is { } color) item.Color = Trim(color, 64);
|
||||
if (req.ImageName is { } imageName) item.ImageName = Trim(imageName, 256);
|
||||
if (req.AssetName is { } assetName) item.AssetName = Trim(assetName, 256);
|
||||
if (req.IsPublic is { } isPublic) item.IsPublic = isPublic;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
/// <summary>DELETE api/customAvatarItems/v1/{id} — verb ordinal 4 on the
|
||||
/// same String.Format("{0}/v1/{1}") URL
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:2188-2199); the client is
|
||||
/// fire-and-forget, so the missing route made deleted shirts silently
|
||||
/// reappear on the next list refresh.
|
||||
///
|
||||
/// <see cref="CustomAvatarItemEntity"/> has no IsDeleted column (unlike
|
||||
/// UgcPurchasableEntity), and adding one is a schema migration, so this
|
||||
/// is a real delete: the ownership rows go first so nobody keeps the
|
||||
/// item in their wardrobe, then the item row itself.</summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
||||
if (item is null) return Ok();
|
||||
if (item.CreatorPlayerId != pid) return Forbid();
|
||||
|
||||
var ownership = await db.CustomAvatarItemOwnership
|
||||
.Where(o => o.CustomAvatarItemId == item.Id)
|
||||
.ToListAsync();
|
||||
if (ownership.Count > 0) db.CustomAvatarItemOwnership.RemoveRange(ownership);
|
||||
db.CustomAvatarItems.Remove(item);
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"[customAvatarItems] deleted {PublicId} by {Pid} ({Owners} ownership rows removed)",
|
||||
id, pid, ownership.Count);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>GET/POST api/customAvatarItems/v1/bulk — resolve the custom
|
||||
/// items other players are wearing. The guid-list parameter key the
|
||||
/// client uses is the literal <c>"customAvatarItemIds"</c>
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:232-244, POST
|
||||
/// ordinal rdx=2), which no read path here honoured, and the old
|
||||
/// <c>[FromBody] BulkRequest</c> parameter made ASP.NET reject a
|
||||
/// form-urlencoded body with 415 before the handler ran. Result: an
|
||||
/// empty array, remote players' shirts never rendered, and the client
|
||||
/// re-queried the same ids forever — the same failure the ugcPurchasables
|
||||
/// bulk endpoint hit. The binding is gone; <see cref="ReadIdsAsync"/>
|
||||
/// now reads the key from the JSON body, the form and the query.</summary>
|
||||
[HttpGet("bulk")]
|
||||
[HttpPost("bulk")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
var ids = await ReadIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("featured")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Featured([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic && i.IsFeatured)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("hot")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Hot([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic)
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.ThenByDescending(i => i.PurchaseCount)
|
||||
.ThenByDescending(i => i.UpdatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("new")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> New([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic)
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("fromCreator/{creatorId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> FromCreator(
|
||||
long creatorId,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 100)
|
||||
{
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var currentPlayerId = this.CurrentPlayerId();
|
||||
var query = db.CustomAvatarItems
|
||||
.Where(i => i.CreatorPlayerId == creatorId
|
||||
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId));
|
||||
var total = await query.CountAsync();
|
||||
var rows = await query
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
// Paged container, not a bare array — the client deserializes this into
|
||||
// CKFLFJCNEPH {Results, TotalResults}, same as Owned() below.
|
||||
return Ok(new { Results = rows.Select(ToWire), TotalResults = total });
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var createdQuery = db.CustomAvatarItems
|
||||
.Where(i => i.CreatorPlayerId == pid)
|
||||
.OrderByDescending(i => i.UpdatedAt);
|
||||
var total = await createdQuery.CountAsync();
|
||||
var created = await createdQuery
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
var ownedIds = await db.CustomAvatarItemOwnership
|
||||
.Where(o => o.PlayerId == pid)
|
||||
.Select(o => o.CustomAvatarItemId)
|
||||
.ToListAsync();
|
||||
var owned = ownedIds.Count == 0
|
||||
? new List<CustomAvatarItemEntity>()
|
||||
: await db.CustomAvatarItems
|
||||
.Where(i => ownedIds.Contains(i.Id))
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Results = created.Select(ToWire),
|
||||
TotalResults = total,
|
||||
Created = created.Select(ToWire),
|
||||
Owned = owned.Select(ToWire),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("owned")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Owned([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var query = db.CustomAvatarItemOwnership
|
||||
.Where(o => o.PlayerId == pid)
|
||||
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
|
||||
.OrderByDescending(i => i.UpdatedAt);
|
||||
var total = await query.CountAsync();
|
||||
var rows = await query
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Results = rows.Select(ToWire),
|
||||
TotalResults = total,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("isCreationAllowedForAccount")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IsCreationAllowedForAccount()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var player = await db.Players
|
||||
.Where(p => p.Id == pid)
|
||||
.Select(p => new { p.IsJunior, p.BannedUntil })
|
||||
.FirstOrDefaultAsync();
|
||||
var allowed = player is not null
|
||||
&& !player.IsJunior
|
||||
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
||||
// Bare JSON boolean, like the two sibling gates below: the client's
|
||||
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK()
|
||||
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an
|
||||
// object body threw and the can-create gate blocked shirt creation.
|
||||
return Content(allowed ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("isCreationEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsCreationEnabled() => Content("true", "application/json");
|
||||
|
||||
[HttpGet("isRenderingEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
||||
|
||||
/// <summary>The client DTO (IHLMDLKOLNB) is an object with exactly one
|
||||
/// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere
|
||||
/// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are
|
||||
/// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT
|
||||
/// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft
|
||||
/// ignores the ones that don't match, and a total miss falls back to the
|
||||
/// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing.</summary>
|
||||
[HttpGet("minPriceForPublicItem")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult MinPriceForPublicItem() => Ok(new
|
||||
{
|
||||
MinPrice = MinPublicItemPrice,
|
||||
Price = MinPublicItemPrice,
|
||||
MinimumPrice = MinPublicItemPrice,
|
||||
MinPriceForPublicItem = MinPublicItemPrice,
|
||||
});
|
||||
|
||||
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
|
||||
/// saved in-progress shirt design. Verb ordinal 0 = GET
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4782-4783); only POST was registered,
|
||||
/// so the designer logged "Unable to get custom avatar design info" every
|
||||
/// time it opened.
|
||||
///
|
||||
/// The response DTO NCMMAGDIBND is {Int32 @0x10, Int32? @0x14, String
|
||||
/// @0x20, String @0x28} (NCMMAGDIBND.txt:3-145). The obfuscator's name
|
||||
/// map is per-property-name rather than per-member, so those four
|
||||
/// accessors identify the properties: IDELLAGIFAE/MOKDHHGDMJB is the same
|
||||
/// pair used for BaseAvatarItemId on GIOBJIGEMNG and DCKDCBDHDKI,
|
||||
/// GJEFKPNEFGI is the Color set from ColorUtility.ToHtmlStringRGB, and
|
||||
/// DPJMMBLGKGF / BADCCGPNLMN are the leading int and the first string of
|
||||
/// NOAAFNFJPFB — the creator id and the image name. Hence
|
||||
/// {CreatorPlayerId, BaseAvatarItemId, ImageName, Color}. The extra keys
|
||||
/// are inert aliases.
|
||||
///
|
||||
/// Returns an empty design rather than 404 when nothing is saved so the
|
||||
/// designer opens clean instead of showing the error toast.</summary>
|
||||
[HttpGet("design")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetDesign()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var design = await LoadDesignAsync(pid) ?? new StoredDesign();
|
||||
return Ok(new
|
||||
{
|
||||
CreatorPlayerId = (int)pid,
|
||||
CreatorId = (int)pid,
|
||||
design.BaseAvatarItemId,
|
||||
design.ImageName,
|
||||
ImageUrl = string.IsNullOrWhiteSpace(design.ImageName)
|
||||
? string.Empty
|
||||
: $"https://{domain.Sub("cdn")}/{design.ImageName}",
|
||||
design.Color,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>PUT api/customAvatarItems/v1/design — save the in-progress
|
||||
/// shirt texture. The route only accepted POST, so the designer could
|
||||
/// never persist a design ("Unable to save custom avatar design").
|
||||
///
|
||||
/// Verb ordinal 3 = PUT with a multipart body: named param
|
||||
/// <c>"metadata"</c> plus a file part <c>"design"</c> with filename
|
||||
/// <c>"file.bin"</c>
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:200-227). The
|
||||
/// metadata object GIOBJIGEMNG holds just two properties — a
|
||||
/// Nullable<Int32> base avatar item id at +0x10 and the "#RRGGBB"
|
||||
/// colour string at +0x18 (GIOBJIGEMNG.txt:3-43), written from the
|
||||
/// FKCJDHHJAFF(texture, color, baseItemId) args at MPBLNLMCEDL.txt:4652-4654.
|
||||
/// The client's return type is status-only (LDGADANDBIO), so a bare 200
|
||||
/// is the whole contract.</summary>
|
||||
[HttpPut("design")]
|
||||
[Authorize]
|
||||
[Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
|
||||
public async Task<IActionResult> SaveDesign()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadDesignRequestAsync();
|
||||
var existing = await LoadDesignAsync(pid);
|
||||
|
||||
var design = new StoredDesign
|
||||
{
|
||||
BaseAvatarItemId = req.BaseAvatarItemId ?? existing?.BaseAvatarItemId,
|
||||
Color = Trim(req.Color, 64) is { Length: > 0 } color ? color : existing?.Color ?? string.Empty,
|
||||
ImageName = existing?.ImageName ?? string.Empty,
|
||||
};
|
||||
|
||||
// The texture arrives as the "design" part; accept any single file
|
||||
// part as a fallback for a client that names it differently.
|
||||
var file = Request.HasFormContentType
|
||||
? Request.Form.Files.GetFile("design") ?? Request.Form.Files.FirstOrDefault()
|
||||
: null;
|
||||
if (file is not null && file.Length > 0)
|
||||
{
|
||||
var bytes = await ReadFileBytesAsync(file);
|
||||
var blobName = DesignImageBlobName(pid);
|
||||
await PutBlobAsync(blobName, bytes, SniffImageContentType(bytes), "custom avatar design");
|
||||
design.ImageName = blobName;
|
||||
}
|
||||
|
||||
await SaveDesignAsync(pid, design);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>DELETE api/customAvatarItems/v1/design — discard the saved
|
||||
/// design. Verb ordinal 4 on the same literal
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4862-4864); without it the client
|
||||
/// logged "Unable to delete custom avatar item design".
|
||||
///
|
||||
/// <see cref="IObjectStorage"/> has no delete operation, so the record is
|
||||
/// tombstoned (a Deleted marker that <see cref="LoadDesignAsync"/> reads
|
||||
/// as "no design") and the texture object is truncated to zero bytes so
|
||||
/// the pixels really are gone.</summary>
|
||||
[HttpDelete("design")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteDesign()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
await SaveDesignAsync(pid, new StoredDesign { Deleted = true });
|
||||
await PutBlobAsync(DesignImageBlobName(pid), Array.Empty<byte>(), "image/png", "custom avatar design");
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("design")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Design()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadDesignRequestAsync();
|
||||
var name = (req.Name ?? string.Empty).Trim();
|
||||
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
|
||||
if (name.Length > 128) name = name[..128];
|
||||
|
||||
var description = (req.Description ?? string.Empty).Trim();
|
||||
if (description.Length > 1024) description = description[..1024];
|
||||
|
||||
CustomAvatarItemEntity item;
|
||||
if (req.Id is Guid id)
|
||||
{
|
||||
item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id)
|
||||
?? new CustomAvatarItemEntity { PublicId = id, CreatorPlayerId = pid };
|
||||
if (item.Id != 0 && item.CreatorPlayerId != pid) return Forbid();
|
||||
if (item.Id == 0) db.CustomAvatarItems.Add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
item = new CustomAvatarItemEntity { CreatorPlayerId = pid };
|
||||
db.CustomAvatarItems.Add(item);
|
||||
}
|
||||
|
||||
item.Name = name;
|
||||
item.Description = description;
|
||||
item.Price = Math.Max(MinPublicItemPrice, req.Price ?? item.Price);
|
||||
item.ItemType = req.ItemType ?? item.ItemType;
|
||||
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
|
||||
item.Color = Trim(req.Color, 64);
|
||||
item.ImageName = Trim(req.ImageName, 256);
|
||||
item.AssetName = Trim(req.AssetName, 256);
|
||||
item.IsPublic = req.IsPublic ?? item.IsPublic;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await EnsureOwnershipAsync(pid, item.Id);
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client buys custom items through the
|
||||
/// <c>econ/</c> host prefix — <c>econ/customAvatarItems/v1/{id}/purchase</c>
|
||||
/// — which was never registered, so every custom-shirt purchase 404'd. The
|
||||
/// rooted route below is an alias onto the same handler. <c>requestedPrice</c>
|
||||
/// arrives as a query param and is validated against the item's real price
|
||||
/// so a tampered client can't buy at its own number.</summary>
|
||||
[HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")]
|
||||
[HttpPost("{id:guid}/purchase")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Purchase(Guid id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id && i.IsPublic);
|
||||
if (item is null) return NotFound();
|
||||
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
|
||||
return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) });
|
||||
|
||||
// requestedPrice is baked into the URL the client formats —
|
||||
// "{0}/v1/{1}/purchase/?requestedPrice={2}"
|
||||
// (RecNet.Runtime/MPBLNLMCEDL.txt:5084). It is the price the player was
|
||||
// SHOWN, so a value below the real price means the listing moved under
|
||||
// them (or the request was tampered with); reject instead of silently
|
||||
// charging more than the confirmation dialog said.
|
||||
var requestedPrice = int.TryParse(Request.Query["requestedPrice"].FirstOrDefault(), out var rp) ? rp : (int?)null;
|
||||
if (requestedPrice is { } shown && shown < item.Price)
|
||||
return Ok(new { Success = false, Error = "price_changed", Price = item.Price, Item = ToWire(item) });
|
||||
|
||||
var balance = await level.GetBalanceAsync(pid, 2);
|
||||
if (balance < item.Price)
|
||||
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
|
||||
|
||||
var newBalance = await level.GrantCurrencyAsync(pid, 2, -item.Price, $"customAvatarItem:{item.PublicId}");
|
||||
await EnsureOwnershipAsync(pid, item.Id);
|
||||
item.PurchaseCount += 1;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) });
|
||||
}
|
||||
|
||||
/// <summary>POST api/customAvatarItems/v1/{id}/report — file a moderation
|
||||
/// report against another player's custom shirt. No report route was
|
||||
/// registered anywhere on the server, so the report dialog's "Submit"
|
||||
/// button 404'd and the client toasted "Unable to report custom avatar
|
||||
/// item {0}".
|
||||
///
|
||||
/// Verb ordinal <c>dl=2</c> = POST, on the URL
|
||||
/// String.Format("{0}/v1/{1}/report") over "api/customAvatarItems"
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4261-4275; the failure literal is
|
||||
/// :4301). The body is RAW JSON — JsonConvert.SerializeObject of a
|
||||
/// JEHCPEEKMML handed straight to BNDIAONDFFF.FJLLPHFOOJJ (:4252-4256,
|
||||
/// :4282), NOT form-urlencoded — so the body is read here instead of
|
||||
/// model-bound, exactly like PUT {id} above.
|
||||
///
|
||||
/// The issuing method IALGIAGBJMA (:4079) returns LDGADANDBIO — status
|
||||
/// only. Its continuation chain (0x181F2F300 → 0x1836E6740 →
|
||||
/// BFDGEKKNAPB.PEFKBAEAOPD, :4285-4294) is byte-for-byte the one the
|
||||
/// DELETE {id} path uses (:2206-2215), and that path is satisfied by a
|
||||
/// bare 200, so an empty 200 is the whole response contract here too.
|
||||
///
|
||||
/// JEHCPEEKMML has exactly three members, pinned by field offset from the
|
||||
/// accessor bodies (JEHCPEEKMML.txt:6/86/108) and by the arg-store
|
||||
/// sequence that fills it (:4239-4247): +0x10 the reason enum, +0x18 the
|
||||
/// free-text detail, +0x20 a Nullable<Int32>. Their JSON KEY names
|
||||
/// live in attribute metadata Cpp2IL doesn't render — obfuscated in the
|
||||
/// 2023.06 dump too (EFPKFBKGLCD, dump.cs:1256458-1256469) — so each is
|
||||
/// matched against an alias list, with a positional fallback in
|
||||
/// declaration order for anything unrecognised (Newtonsoft writes members
|
||||
/// in declaration order and the DTO has exactly these three).
|
||||
///
|
||||
/// The reason enum IS readable: LGEPGEHKNAG.KFCMACCLDEJ in the 2023.06
|
||||
/// dump (dump.cs:1250039-1250044) — CoC_Discriminatory=0, CoC_Sexual=1,
|
||||
/// CoC_Trolling=2, Misleading=3, Other=4, InappropriateClothing=5. That is
|
||||
/// a DIFFERENT numbering from <see cref="ReportEntity.Category"/>'s
|
||||
/// RoomieReportCategory convention (Harassment=1 … Other=5), so the
|
||||
/// ordinal is stored verbatim and the readable name is written into the
|
||||
/// message — otherwise a moderator reading the shared queue cannot tell a
|
||||
/// CoC_Sexual=1 clothing report from a Harassment=1 player report.
|
||||
///
|
||||
/// <see cref="ReportEntity"/> has no custom-avatar-item column and adding
|
||||
/// one is a migration, so the reported item's guid is carried in the
|
||||
/// stored text — the same thing the sibling screen-share report handler
|
||||
/// does with its image/instance evidence. That marker prefix is also what
|
||||
/// makes the duplicate check below work.</summary>
|
||||
[HttpPost("{id:guid}/report")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Report(Guid id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
||||
if (item is null) return NotFound();
|
||||
// The report button only renders on someone else's item, so this is an
|
||||
// impossible UI state; reject rather than pollute the queue.
|
||||
if (item.CreatorPlayerId == pid) return BadRequest(new { error = "cannot_report_own_item" });
|
||||
|
||||
var req = await ReadReportRequestAsync();
|
||||
// The dialog refuses to submit without a category ("Cannot report
|
||||
// custom clothing without a report category." —
|
||||
// Assembly-CSharp/RRUI/Data/CustomAvatarItemReportModel.txt:1941), so
|
||||
// a missing reason means a non-stock caller; bucket it as Other=4.
|
||||
var reason = req.Reason ?? 4;
|
||||
|
||||
var context = new List<string> { $"reason={ReportReasonName(reason)}({reason})" };
|
||||
// The only in-game caller passes null for the Nullable<Int32>
|
||||
// (CustomAvatarItemReportModel.txt:2008/2011 push rbx=0 for both it and
|
||||
// the trailing bool), so its meaning is unproven — keep whatever a
|
||||
// client does send rather than silently dropping evidence.
|
||||
if (req.Context is { } ctx) context.Add($"ctx={ctx}");
|
||||
|
||||
var details = (req.Details ?? string.Empty).Trim();
|
||||
var message = $"{ReportMarker(id)}{string.Join(' ', context)}] {details}".Trim();
|
||||
if (message.Length > 1000) message = message[..1000];
|
||||
|
||||
// One open report per (reporter, item): the dialog is re-openable and
|
||||
// the client retries on a failed submit, so without this a single
|
||||
// annoyed player can flood the moderation queue.
|
||||
var marker = ReportMarker(id);
|
||||
var duplicate = await db.Reports.AnyAsync(r =>
|
||||
r.ReporterPlayerId == pid && r.ResolvedAt == null && r.Message.StartsWith(marker));
|
||||
if (duplicate)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[customAvatarItems] duplicate report of {PublicId} by {Pid} ignored", id, pid);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = pid,
|
||||
TargetPlayerId = item.CreatorPlayerId,
|
||||
Category = reason,
|
||||
Message = message,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"[customAvatarItems] reported {PublicId} (creator {Creator}) by {Pid} reason={Reason}",
|
||||
id, item.CreatorPlayerId, pid, ReportReasonName(reason));
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Prefix every stored custom-item report with the item guid so
|
||||
/// the row is attributable without a dedicated column, and so the
|
||||
/// duplicate check can find prior reports with a plain LIKE.</summary>
|
||||
private static string ReportMarker(Guid id) => $"[customAvatarItem {id:D} ";
|
||||
|
||||
/// <summary>LGEPGEHKNAG.KFCMACCLDEJ, verbatim from the 2023.06 dump
|
||||
/// (dump.cs:1250039-1250044). Index IS the wire ordinal.</summary>
|
||||
private static readonly string[] ReportReasonNames =
|
||||
{
|
||||
"CoC_Discriminatory", "CoC_Sexual", "CoC_Trolling",
|
||||
"Misleading", "Other", "InappropriateClothing",
|
||||
};
|
||||
|
||||
private static string ReportReasonName(int reason) =>
|
||||
reason >= 0 && reason < ReportReasonNames.Length ? ReportReasonNames[reason] : "Unknown";
|
||||
|
||||
/// <summary>Read the report body. The stock client sends raw JSON, but
|
||||
/// form encodings are accepted too so an admin/tooling caller can post the
|
||||
/// same three fields flat.</summary>
|
||||
private async Task<ReportRequest> ReadReportRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
string? Field(params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
if (form[key].FirstOrDefault() is { } v) return v;
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReportRequest
|
||||
{
|
||||
Reason = ParseReportReason(Field("reason", "Reason", "reportReason", "ReportReason",
|
||||
"category", "Category", "reportCategory", "ReportCategory", "type", "Type")),
|
||||
Details = Field("details", "Details", "description", "Description",
|
||||
"message", "Message", "comment", "Comment"),
|
||||
Context = int.TryParse(
|
||||
Field("contextId", "ContextId", "instanceId", "InstanceId",
|
||||
"roomInstanceId", "RoomInstanceId", "roomId", "RoomId",
|
||||
"accountId", "AccountId", "playerId", "PlayerId"),
|
||||
out var ctx)
|
||||
? ctx
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
return ParseReportJson(await reader.ReadToEndAsync());
|
||||
}
|
||||
|
||||
/// <summary>Lenient JSON → <see cref="ReportRequest"/>. Alias-matched
|
||||
/// first; anything left over is assigned positionally in JEHCPEEKMML's
|
||||
/// declaration order (reason, detail, nullable int) so an unguessed key
|
||||
/// spelling still lands in the right slot.</summary>
|
||||
private static ReportRequest ParseReportJson(string json)
|
||||
{
|
||||
var req = new ReportRequest();
|
||||
if (string.IsNullOrWhiteSpace(json)) return req;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return req;
|
||||
|
||||
var unmatched = new List<JsonElement>();
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "reason" or "reportreason" or "category" or "reportcategory" or "type" or "reporttype":
|
||||
req.Reason ??= ParseReportReason(prop.Value);
|
||||
break;
|
||||
case "details" or "detail" or "description" or "reportdescription"
|
||||
or "message" or "comment" or "text":
|
||||
req.Details ??= AsString(prop.Value);
|
||||
break;
|
||||
case "contextid" or "instanceid" or "roominstanceid" or "roomid"
|
||||
or "accountid" or "playerid" or "reportedplayerid":
|
||||
req.Context ??= AsInt(prop.Value);
|
||||
break;
|
||||
default:
|
||||
unmatched.Add(prop.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var value in unmatched)
|
||||
{
|
||||
if (req.Reason is null && ParseReportReason(value) is { } reason)
|
||||
{
|
||||
req.Reason = reason;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (req.Details is null && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
req.Details = value.GetString();
|
||||
continue;
|
||||
}
|
||||
|
||||
req.Context ??= AsInt(value);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
/// <summary>Accept the reason as an ordinal or as one of the enum's own
|
||||
/// names — Newtonsoft writes an int by default, but a StringEnumConverter
|
||||
/// on the DTO (an attribute the dump doesn't render) would write the name.</summary>
|
||||
private static int? ParseReportReason(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null,
|
||||
JsonValueKind.String => ParseReportReason(value.GetString()),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static int? ParseReportReason(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
if (int.TryParse(value, out var ordinal)) return ordinal;
|
||||
var index = Array.FindIndex(
|
||||
ReportReasonNames,
|
||||
n => string.Equals(n, value, StringComparison.OrdinalIgnoreCase));
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
|
||||
private async Task EnsureOwnershipAsync(long playerId, long itemId)
|
||||
{
|
||||
if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
|
||||
o.PlayerId == playerId && o.CustomAvatarItemId == itemId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.CustomAvatarItemOwnership.Add(new CustomAvatarItemOwnershipEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
CustomAvatarItemId = itemId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Collect the bulk request's guid list. The 2023 client's key is
|
||||
/// <c>"customAvatarItemIds"</c> (MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:242);
|
||||
/// older callers use ids/itemIds. Which transport the request builder picks
|
||||
/// for a POST collection param isn't decidable from ISIL, so all three are
|
||||
/// read: raw JSON body, form fields and query pairs. The body is read
|
||||
/// manually — a [FromBody] parameter would 415 a form-encoded POST before
|
||||
/// the handler ran.</summary>
|
||||
private async Task<List<Guid>> ReadIdsAsync()
|
||||
{
|
||||
var result = new List<Guid>();
|
||||
void AddCsv(string? value)
|
||||
{
|
||||
foreach (var part in (value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (Guid.TryParse(part, out var id)) result.Add(id);
|
||||
}
|
||||
|
||||
if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
// Either a bare guid array or an object carrying one under any
|
||||
// of the accepted key spellings.
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
AddJsonGuids(root, result);
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (!IdListKeys.Contains(prop.Name)) continue;
|
||||
if (prop.Value.ValueKind == JsonValueKind.Array) AddJsonGuids(prop.Value, result);
|
||||
else if (prop.Value.ValueKind == JsonValueKind.String) AddCsv(prop.Value.GetString());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pair in Request.Query)
|
||||
foreach (var value in pair.Value)
|
||||
AddCsv(value);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var field in form)
|
||||
{
|
||||
if (!IdListKeys.Contains(field.Key)) continue;
|
||||
foreach (var value in field.Value) AddCsv(value);
|
||||
}
|
||||
}
|
||||
|
||||
return result.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private static void AddJsonGuids(JsonElement array, List<Guid> into)
|
||||
{
|
||||
foreach (var el in array.EnumerateArray())
|
||||
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var id))
|
||||
into.Add(id);
|
||||
}
|
||||
|
||||
/// <summary>Accepted spellings of the bulk guid-list key. Case-insensitive
|
||||
/// set so "customAvatarItemIds" / "CustomAvatarItemIds" both land.</summary>
|
||||
private static readonly HashSet<string> IdListKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"customAvatarItemIds", "ids", "itemIds",
|
||||
};
|
||||
|
||||
/// <summary>Read the fields of a create / update / design-save request.
|
||||
/// Three encodings have to work: the designer's multipart bodies wrap
|
||||
/// everything in a single <c>"metadata"</c> form field holding JSON
|
||||
/// (MPBLNLMCEDL.txt:1362 and MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:210),
|
||||
/// PUT {id} sends a raw JSON body, and older/admin callers post flat form
|
||||
/// fields. The JSON key names are not in the dump — they live in attribute
|
||||
/// metadata Cpp2IL doesn't render — so each field is matched against an
|
||||
/// alias list rather than one guessed spelling. Only the field ORDER is
|
||||
/// proven (from the arg-store sequence); the names are inferred.</summary>
|
||||
private async Task<DesignRequest> ReadDesignRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var metadata = form["metadata"].FirstOrDefault() ?? form["Metadata"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(metadata))
|
||||
return ParseDesignJson(metadata);
|
||||
|
||||
string? Field(params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
if (form[key].FirstOrDefault() is { } v) return v;
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DesignRequest
|
||||
{
|
||||
Id = Guid.TryParse(Field("id", "Id"), out var id) ? id : null,
|
||||
Name = Field("name", "Name"),
|
||||
Description = Field("description", "Description"),
|
||||
Price = int.TryParse(Field("price", "Price"), out var price) ? price : null,
|
||||
ItemType = int.TryParse(Field("itemType", "ItemType", "permission", "Permission"), out var itemType) ? itemType : null,
|
||||
BaseAvatarItemId = int.TryParse(Field("baseAvatarItemId", "BaseAvatarItemId"), out var baseId) ? baseId : null,
|
||||
Color = Field("color", "Color"),
|
||||
ImageName = Field("imageName", "ImageName"),
|
||||
AssetName = Field("assetName", "AssetName"),
|
||||
IsPublic = bool.TryParse(Field("isPublic", "IsPublic"), out var pub) ? pub : null,
|
||||
};
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
return ParseDesignJson(await reader.ReadToEndAsync());
|
||||
}
|
||||
|
||||
/// <summary>Lenient JSON → <see cref="DesignRequest"/>. Members that are
|
||||
/// absent stay null so callers can tell "not sent" from "sent empty" —
|
||||
/// PUT {id} relies on that to leave untouched fields alone.</summary>
|
||||
private static DesignRequest ParseDesignJson(string json)
|
||||
{
|
||||
var req = new DesignRequest();
|
||||
if (string.IsNullOrWhiteSpace(json)) return req;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return req;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
var value = prop.Value;
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "id" or "customavataritemid":
|
||||
if (value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var id)) req.Id = id;
|
||||
break;
|
||||
case "name" or "itemname" or "title":
|
||||
req.Name = AsString(value);
|
||||
break;
|
||||
case "description" or "desc":
|
||||
req.Description = AsString(value);
|
||||
break;
|
||||
case "price":
|
||||
req.Price = AsInt(value);
|
||||
break;
|
||||
// The enum at DCKDCBDHDKI+0x38 / NOAAFNFJPFB+0x3C is the
|
||||
// item's permission (the 06.21 dump names its config row
|
||||
// CustomAvatarItemPermissionFriendlyNameMapping); this
|
||||
// schema calls that column ItemType, hence both aliases.
|
||||
case "itemtype" or "type" or "permission" or "avataritemtype":
|
||||
req.ItemType = AsInt(value);
|
||||
break;
|
||||
case "baseavataritemid" or "baseitemid" or "avataritemid":
|
||||
req.BaseAvatarItemId = AsInt(value);
|
||||
break;
|
||||
case "color" or "colorhex" or "tint":
|
||||
req.Color = AsString(value);
|
||||
break;
|
||||
case "imagename" or "thumbnail" or "thumbnailimage":
|
||||
req.ImageName = AsString(value);
|
||||
break;
|
||||
case "assetname" or "design" or "designname":
|
||||
req.AssetName = AsString(value);
|
||||
break;
|
||||
case "ispublic" or "public":
|
||||
req.IsPublic = value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String => bool.TryParse(value.GetString(), out var b) ? b : null,
|
||||
_ => null,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
private static string? AsString(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Number => value.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static int? AsInt(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null,
|
||||
JsonValueKind.String => int.TryParse(value.GetString(), out var s) ? s : null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Pick out the create request's two file parts. Named lookup
|
||||
/// first ("thumbnailImage" / "design", both sent with filename
|
||||
/// "file.bin" — MPBLNLMCEDL.txt:1372-1390), then positional order as a
|
||||
/// fallback.</summary>
|
||||
private (IFormFile? Thumbnail, IFormFile? Asset) ReadItemFileParts()
|
||||
{
|
||||
if (!Request.HasFormContentType) return (null, null);
|
||||
var files = Request.Form.Files;
|
||||
if (files.Count == 0) return (null, null);
|
||||
var thumbnail = files.GetFile("thumbnailImage") ?? files.GetFile("thumbnail");
|
||||
var asset = files.GetFile("design") ?? files.GetFile("asset");
|
||||
if (thumbnail is null && asset is null)
|
||||
{
|
||||
thumbnail = files[0];
|
||||
asset = files.Count > 1 ? files[1] : null;
|
||||
}
|
||||
|
||||
return (thumbnail?.Length > 0 ? thumbnail : null, asset?.Length > 0 ? asset : null);
|
||||
}
|
||||
|
||||
/// <summary>Persist one uploaded image part and return its BlobName. Same
|
||||
/// shape as the images controller: S3 (or the disk fallback) holds the
|
||||
/// bytes, a RoomDataBlobs row keeps the uploader audit trail, and the
|
||||
/// cdn serves it straight off the BlobName.</summary>
|
||||
private async Task<string> StoreImageBlobAsync(long playerId, IFormFile file, string what)
|
||||
{
|
||||
var bytes = await ReadFileBytesAsync(file);
|
||||
var contentType = SniffImageContentType(bytes);
|
||||
var blobName = $"img_p{playerId}_{Guid.NewGuid():N}.{(contentType == "image/jpeg" ? "jpg" : "png")}";
|
||||
await PutBlobAsync(blobName, bytes, contentType, what);
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = 0,
|
||||
BlobName = blobName,
|
||||
UploadedByPlayerId = playerId,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
ReferencedFilenamesCsv = string.Empty,
|
||||
});
|
||||
return blobName;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadFileBytesAsync(IFormFile file)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>The parts are sent with filename "file.bin" and no usable
|
||||
/// content type, so sniff the magic bytes instead of trusting either.</summary>
|
||||
private static string SniffImageContentType(byte[] bytes) =>
|
||||
bytes.Length >= 4 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 ? "image/png"
|
||||
: bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF ? "image/jpeg"
|
||||
: "image/png";
|
||||
|
||||
private async Task PutBlobAsync(string blobName, byte[] bytes, string contentType, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
await storage.PutAsync(bucket, key, bytes, contentType, timeout.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[customAvatarItems] storing {What} blob {Blob} failed", what, blobName);
|
||||
}
|
||||
}
|
||||
|
||||
// ── In-progress shirt design ────────────────────────────────────────────
|
||||
// The designer's draft (base item + colour + texture) has no table in this
|
||||
// schema and adding one is a migration, so it lives in object storage
|
||||
// under two deterministic per-player blob names: overwrite-in-place on
|
||||
// save, tombstoned on delete.
|
||||
private static string DesignMetaBlobName(long playerId) => $"caidesign_p{playerId}.json";
|
||||
|
||||
private static string DesignImageBlobName(long playerId) => $"caidesign_p{playerId}.png";
|
||||
|
||||
private async Task<StoredDesign?> LoadDesignAsync(long playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
var (bucket, key) = BlobRouter.Route(DesignMetaBlobName(playerId));
|
||||
var bytes = await storage.GetAsync(bucket, key, timeout.Token);
|
||||
if (bytes is null || bytes.Length == 0) return null;
|
||||
var design = JsonSerializer.Deserialize<StoredDesign>(
|
||||
bytes,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return design is null || design.Deleted ? null : design;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
logger.LogWarning(ex, "[customAvatarItems] unreadable saved design for player {Pid}", playerId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Task SaveDesignAsync(long playerId, StoredDesign design) =>
|
||||
PutBlobAsync(
|
||||
DesignMetaBlobName(playerId),
|
||||
JsonSerializer.SerializeToUtf8Bytes(design),
|
||||
"application/json",
|
||||
"custom avatar design record");
|
||||
|
||||
private object ToWire(CustomAvatarItemEntity item) => new
|
||||
{
|
||||
Id = item.PublicId,
|
||||
CustomAvatarItemId = item.PublicId,
|
||||
CreatorId = (int)item.CreatorPlayerId,
|
||||
CreatorPlayerId = (int)item.CreatorPlayerId,
|
||||
item.Name,
|
||||
item.Description,
|
||||
item.Price,
|
||||
item.ItemType,
|
||||
item.BaseAvatarItemId,
|
||||
item.Color,
|
||||
item.ImageName,
|
||||
ImageUrl = string.IsNullOrWhiteSpace(item.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{item.ImageName}",
|
||||
item.AssetName,
|
||||
item.IsPublic,
|
||||
item.IsFeatured,
|
||||
item.CheerCount,
|
||||
item.PurchaseCount,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
Status = item.IsPublic ? 1 : 0,
|
||||
};
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
public sealed class BulkRequest
|
||||
{
|
||||
public List<Guid>? Ids { get; set; }
|
||||
public List<Guid>? ItemIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The designer's in-progress draft, as persisted to the
|
||||
/// per-player <c>caidesign_p<id>.json</c> object (see
|
||||
/// <see cref="LoadDesignAsync"/>). Mirrors the three payload properties of
|
||||
/// the client's design DTOs — the metadata object GIOBJIGEMNG carries the
|
||||
/// nullable base-item id and the "#RRGGBB" colour, and the texture arrives
|
||||
/// as the separate "design" file part whose stored blob name lands in
|
||||
/// <see cref="ImageName"/>.
|
||||
///
|
||||
/// <see cref="Deleted"/> is the tombstone: <see cref="IObjectStorage"/>
|
||||
/// exposes no delete operation, so DELETE design overwrites the record with
|
||||
/// a deleted marker and LoadDesignAsync reports it as "no saved design".
|
||||
/// Public properties with setters because the round-trip goes through
|
||||
/// System.Text.Json.</summary>
|
||||
public sealed class StoredDesign
|
||||
{
|
||||
public int? BaseAvatarItemId { get; set; }
|
||||
|
||||
public string Color { get; set; } = string.Empty;
|
||||
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
public bool Deleted { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The three members of the client's report DTO JEHCPEEKMML,
|
||||
/// in its declaration order: the reason enum (+0x10), the free-text
|
||||
/// detail (+0x18) and an unidentified Nullable<Int32> (+0x20) that
|
||||
/// the stock report dialog always leaves null. All nullable so
|
||||
/// "not sent" is distinguishable from "sent empty".</summary>
|
||||
public sealed class ReportRequest
|
||||
{
|
||||
public int? Reason { get; set; }
|
||||
public string? Details { get; set; }
|
||||
public int? Context { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DesignRequest
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? Price { get; set; }
|
||||
public int? ItemType { get; set; }
|
||||
public int? BaseAvatarItemId { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public string? AssetName { get; set; }
|
||||
public bool? IsPublic { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,34 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Events.V3;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v3")]
|
||||
public class EventsController : ControllerBase
|
||||
public class EventsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("list")]
|
||||
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
|
||||
public async Task<ActionResult<List<object>>> GetActiveEvents([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(take)
|
||||
.Select(e => new
|
||||
{
|
||||
EventId = e.Id,
|
||||
PlayerEventId = e.Id,
|
||||
Name = e.Title,
|
||||
e.Description,
|
||||
StartTime = e.StartsAt,
|
||||
EndTime = e.EndsAt,
|
||||
CreatorPlayerId = (int)e.CreatorPlayerId,
|
||||
e.RoomId,
|
||||
e.Capacity,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows.Cast<object>().ToList());
|
||||
}
|
||||
}
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ExternalFriendInvite;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
/// <summary>Key prefix for "player X redeemed one of player Y's invites".
|
||||
/// Row layout is <c>PlayerId = the invitee</c>,
|
||||
/// <c>Key = externalinvite:redeemed:{kind}:{inviterAccountId}</c>,
|
||||
/// <c>Value = redemption timestamp</c>. The referrer endpoints below read
|
||||
/// these rows; the invite-claim path is what writes them.</summary>
|
||||
private const string RedeemedPrefix = "externalinvite:redeemed:";
|
||||
|
||||
/// <summary>Records an out-of-game friend invite aimed at a platform (Steam,
|
||||
/// Oculus, …) account.
|
||||
///
|
||||
/// The client only ever sends the single form field <c>platformId</c>
|
||||
/// (RecNet.Runtime/OMHDDLFIPLP.txt:876) — <c>platform</c> keeps its default
|
||||
/// so the older callers that do send it still bind.</summary>
|
||||
[HttpPost("api/externalfriendinvite/v1/createplatforminvite")]
|
||||
public async Task<IActionResult> CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null)
|
||||
{
|
||||
var code = NewInviteCode();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = $"externalinvite:platform:{code}",
|
||||
Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var url = $"{Request.Scheme}://{Request.Host}/invite/{code}";
|
||||
var result = Success();
|
||||
result["InviteCode"] = code;
|
||||
result["InviteUrl"] = url;
|
||||
result["Platform"] = platform;
|
||||
result["PlatformId"] = platformId ?? string.Empty;
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Records an SMS friend invite.
|
||||
///
|
||||
/// Form fields are <c>phoneNumber</c>, <c>friendCode</c> and
|
||||
/// <c>senderName</c> (OMHDDLFIPLP.txt:497/505/515) — there is no "message"
|
||||
/// field, so the friend code that the text is supposed to carry has to be
|
||||
/// persisted from <c>friendCode</c> or it is lost.</summary>
|
||||
[HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")]
|
||||
public async Task<IActionResult> SendTextMessageInvite(
|
||||
[FromForm] string? phoneNumber,
|
||||
[FromForm] string? friendCode,
|
||||
[FromForm] string? senderName)
|
||||
{
|
||||
var code = NewInviteCode();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = $"externalinvite:text:{code}",
|
||||
Value = $"{phoneNumber ?? string.Empty}|{friendCode ?? string.Empty}|{senderName ?? string.Empty}|{DateTime.UtcNow:O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var result = Success();
|
||||
result["InviteCode"] = code;
|
||||
result["PhoneNumber"] = phoneNumber ?? string.Empty;
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Ids of the accounts that referred the caller through a platform
|
||||
/// invite.
|
||||
///
|
||||
/// The response is a bare array of account ids — the issuing method is
|
||||
/// <c>FGLDKEJLAKB<List<System.Int32>> POJMMCCLNCB()</c>
|
||||
/// (RecNet.Runtime/OMHDDLFIPLP.txt:923-996, body-less POST). Returning
|
||||
/// objects (<c>{InviteCode, Kind, Value, CreatedAt}</c>) made the strict
|
||||
/// Int32 reader throw as soon as any row existed, which faulted
|
||||
/// SessionManager.CheckForAndHandlePlatformFriendOrTextMessageReferrers
|
||||
/// (SessionManager.txt:31439) during the login bootstrap.</summary>
|
||||
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
|
||||
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
|
||||
public async Task<IActionResult> GetPlatformReferrers()
|
||||
=> Ok(await ReferrerIdsAsync("platform"));
|
||||
|
||||
/// <summary>Same bare-id-array contract as
|
||||
/// <see cref="GetPlatformReferrers"/> (OMHDDLFIPLP.txt:998-1071), for
|
||||
/// text-message invites.</summary>
|
||||
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
|
||||
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
|
||||
public async Task<IActionResult> GetTextMessageReferrers()
|
||||
=> Ok(await ReferrerIdsAsync("text"));
|
||||
|
||||
/// <summary>Account ids recorded as having referred the caller with an
|
||||
/// invite of the given kind. The inviter id is the trailing key segment of
|
||||
/// the redemption row, so no id can leak in from an invite's own payload
|
||||
/// (a numeric <c>platformId</c> used to be misread as an account id).</summary>
|
||||
private async Task<List<int>> ReferrerIdsAsync(string kind)
|
||||
{
|
||||
var prefix = RedeemedPrefix + kind + ":";
|
||||
var keys = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == Me && s.Key.StartsWith(prefix))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.Select(s => s.Key)
|
||||
.ToListAsync();
|
||||
|
||||
return keys
|
||||
.Select(k => int.TryParse(k[prefix.Length..], out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NewInviteCode() => Guid.NewGuid().ToString("N")[..10];
|
||||
|
||||
/// <summary>Both write routes await <c>FGLDKEJLAKB<DJMHAFPGLLN></c> and
|
||||
/// hand it to <c>BFDGEKKNAPB.ExpandRecNetResult</c>, whose lambda pops the
|
||||
/// <c>Error</c> string as an error toast whenever <c>Success</c> is false
|
||||
/// (BFDGEKKNAPB_NestedType___c.txt:61 <ExpandRecNetResult>b__2_0).
|
||||
/// Utf8Json leaves absent members at their default, so a response without
|
||||
/// these two keys reads as Success=false and every successful invite raised
|
||||
/// an empty error toast. Key names are literal from the generated formatter
|
||||
/// (LMCCNLLHLCJ.txt:191 "Success", :210 "Error"; it also accepts the
|
||||
/// camelCase aliases at :202/:218, and skips members it does not know, so
|
||||
/// the per-route extras below are inert).</summary>
|
||||
private static Dictionary<string, object?> Success() => new(StringComparer.Ordinal)
|
||||
{
|
||||
["Success"] = true,
|
||||
["Error"] = string.Empty,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.FreeGifts;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class FreeGiftsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/freegifts/v1/sendmultiple")]
|
||||
public async Task<IActionResult> SendMultiple()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var sender = this.RequireCurrentPlayerId();
|
||||
var recipients = ReadLongList(fields, "recipientPlayerIds", "RecipientPlayerIds", "playerIds", "PlayerIds");
|
||||
if (recipients.Count == 0) return BadRequest("missing_recipients");
|
||||
|
||||
var currencyType = ReadInt(fields, "currencyType", "CurrencyType") ?? 2;
|
||||
var currency = Math.Max(0, ReadInt(fields, "currency", "Currency") ?? 0);
|
||||
var xp = Math.Max(0, ReadInt(fields, "xp", "Xp") ?? 0);
|
||||
var message = ReadString(fields, "message", "Message") ?? string.Empty;
|
||||
var created = new List<(long RecipientPlayerId, GiftPackageEntity Gift)>();
|
||||
|
||||
foreach (var recipient in recipients.Distinct().Take(100))
|
||||
{
|
||||
if (!await db.Players.AnyAsync(p => p.Id == recipient)) continue;
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = recipient,
|
||||
FromPlayerId = (int)sender,
|
||||
CurrencyType = currency > 0 ? currencyType : 0,
|
||||
Currency = currency,
|
||||
Xp = xp,
|
||||
Level = 1,
|
||||
GiftContext = 0,
|
||||
GiftRarity = 0,
|
||||
Message = message,
|
||||
Platform = -1,
|
||||
PackageVariant = "FreeGift",
|
||||
PackageMaterial = string.Empty,
|
||||
Consumed = false,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
created.Add((recipient, gift));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
foreach (var row in created)
|
||||
{
|
||||
await notifications.NotifyAsync(row.RecipientPlayerId,
|
||||
PushNotificationId.GiftPackageReceived,
|
||||
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
|
||||
}
|
||||
|
||||
return Ok(new { Success = true, Sent = created.Count });
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
fields[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
|
||||
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
|
||||
_ => prop.Value.GetRawText(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
|
||||
int.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
|
||||
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
: Array.Empty<string>())
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(100)
|
||||
.ToList();
|
||||
}
|
||||
@@ -9,12 +9,31 @@ using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameRewards;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/gamerewards/v1/* — the post-activity "choose 1 of 3"
|
||||
/// item reward the client's <c>RewardManager</c> shows after a Stunt
|
||||
/// Runner run (RewardType <c>PostGameActivity=2</c>) or an RRO quest
|
||||
/// (<c>PostQuestActivity=3</c>). The client POSTs <c>/request</c> with a
|
||||
/// rewardType + giftContext (no score/rank on the wire — the rank scales
|
||||
/// the separate <b>currency</b> reward, which arrives via
|
||||
/// <c>storefronts/v2/balance</c> as a rank-derived multiplier that
|
||||
/// <see cref="Store.StorefrontsController.ModifyBalance"/> honours), then
|
||||
/// polls <c>/pending</c> and picks one via <c>/select</c>.
|
||||
///
|
||||
/// Each of GiftDrop1/2/3 is a real store item built through
|
||||
/// <see cref="StoreService.BuildGiftDrop"/> — the SAME wire-verified
|
||||
/// EFFIEFEFHHB shape the storefront uses (notably AvatarItemId is an
|
||||
/// Int32), so the strict 2023 reader doesn't reject the selection. On
|
||||
/// <c>/select</c> the chosen item is granted to inventory for free.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class GameRewardsController(DorkNetDbContext db, LevelService level) : ControllerBase
|
||||
public class GameRewardsController(DorkNetDbContext db, LevelService level, StoreService store) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
private const int OfferCount = 3;
|
||||
|
||||
[HttpGet("api/gamerewards/v1/pending")]
|
||||
public async Task<IActionResult> Pending()
|
||||
{
|
||||
@@ -24,7 +43,8 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.Take(20)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
var items = await LoadOfferedItemsAsync(rows);
|
||||
return Ok(rows.Select(r => ToWire(r, items)));
|
||||
}
|
||||
|
||||
[HttpPost("api/gamerewards/v1/request")]
|
||||
@@ -32,15 +52,27 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co
|
||||
public async Task<IActionResult> RequestReward()
|
||||
{
|
||||
var req = await ReadRewardRequestAsync();
|
||||
var pid = Me;
|
||||
var row = new GameRewardSelectionEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
PlayerId = pid,
|
||||
RewardType = req.RewardType,
|
||||
GiftContext = req.GiftContext,
|
||||
Message = string.IsNullOrWhiteSpace(req.Message) ? "Choose a reward" : req.Message[..Math.Min(req.Message.Length, 256)],
|
||||
Message = string.IsNullOrWhiteSpace(req.Message)
|
||||
? "Choose a reward"
|
||||
: req.Message[..Math.Min(req.Message.Length, 256)],
|
||||
};
|
||||
db.GameRewardSelections.Add(row);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Pick the three offered items now that the row has an id (used as
|
||||
// the deterministic seed) and persist them so /pending is stable
|
||||
// and /select grants exactly what was shown.
|
||||
var offered = await store.PickRewardItemsAsync(OfferCount, unchecked((int)row.Id));
|
||||
if (offered.Count > 0) row.Offer1ItemId = offered[0].Id;
|
||||
if (offered.Count > 1) row.Offer2ItemId = offered[1].Id;
|
||||
if (offered.Count > 2) row.Offer3ItemId = offered[2].Id;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
@@ -54,46 +86,61 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co
|
||||
.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" });
|
||||
|
||||
// Map the client's chosen GiftDropId back to one of the offered
|
||||
// store items. The GiftDropId is the item's PurchasableItemId.
|
||||
var offeredIds = new[] { row.Offer1ItemId, row.Offer2ItemId, row.Offer3ItemId }
|
||||
.Where(id => id > 0)
|
||||
.ToList();
|
||||
var items = await db.StoreItems.Where(i => offeredIds.Contains(i.Id)).ToListAsync();
|
||||
var chosen = items.FirstOrDefault(i => StoreService.PurchasableItemIdFor(i) == req.GiftDropId)
|
||||
?? items.FirstOrDefault();
|
||||
|
||||
row.SelectedAt = DateTime.UtcNow;
|
||||
row.SelectedGiftDropId = req.GiftDropId;
|
||||
if (chosen is not null)
|
||||
{
|
||||
await store.GrantItemAsync(pid, chosen.Id);
|
||||
row.GrantedItemId = chosen.Id;
|
||||
}
|
||||
// Small token + XP kicker on top of the item, matching the
|
||||
// post-activity "you also banked a little" feel.
|
||||
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
|
||||
private async Task<Dictionary<long, StoreItemEntity>> LoadOfferedItemsAsync(
|
||||
IEnumerable<GameRewardSelectionEntity> rows)
|
||||
{
|
||||
var ids = rows
|
||||
.SelectMany(r => new[] { r.Offer1ItemId, r.Offer2ItemId, r.Offer3ItemId })
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (ids.Count == 0) return new();
|
||||
return await db.StoreItems
|
||||
.Where(i => ids.Contains(i.Id))
|
||||
.ToDictionaryAsync(i => i.Id);
|
||||
}
|
||||
|
||||
private static object ToWire(GameRewardSelectionEntity row, IReadOnlyDictionary<long, StoreItemEntity> items) => 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),
|
||||
GiftDrop1 = OfferDrop(row.Offer1ItemId, items),
|
||||
GiftDrop2 = OfferDrop(row.Offer2ItemId, items),
|
||||
GiftDrop3 = OfferDrop(row.Offer3ItemId, items),
|
||||
row.CreatedAt,
|
||||
};
|
||||
|
||||
private static object GiftDrop(long id, string name, int currency) => new
|
||||
private static object? OfferDrop(long itemId, IReadOnlyDictionary<long, StoreItemEntity> items)
|
||||
{
|
||||
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,
|
||||
};
|
||||
if (itemId <= 0 || !items.TryGetValue(itemId, out var item)) return null;
|
||||
return StoreService.BuildGiftDrop(item, StoreService.PurchasableItemIdFor(item));
|
||||
}
|
||||
|
||||
private async Task<RewardRequest> ReadRewardRequestAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameSight;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
public class GameSightController : ControllerBase
|
||||
{
|
||||
[HttpPost("/api/gamesight/event")]
|
||||
[HttpPost("/data/event")]
|
||||
[HttpPost("/data/heartbeat")]
|
||||
public IActionResult Event() => Ok(new { Success = true });
|
||||
}
|
||||
@@ -274,6 +274,89 @@ public class ImagesController(
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
|
||||
}
|
||||
|
||||
public sealed class ImageBulkRequest
|
||||
{
|
||||
public List<long>? ImageIds { get; set; }
|
||||
public List<long>? SavedImageIds { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("api/images/v5/bulk")]
|
||||
[HttpPost("api/images/v5/bulk")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BulkImages([FromBody] ImageBulkRequest? body)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var ids = (body?.ImageIds ?? body?.SavedImageIds ?? new List<long>())
|
||||
.Concat(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 : 0))
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var rows = await db.Photos
|
||||
.Where(p => ids.Contains(p.Id) && p.DeletedAt == null
|
||||
&& (p.IsPublic || p.UploaderPlayerId == pid))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v6?name={blobName}</c> — single-image
|
||||
/// lookup by name. Client contract (RecNet.Runtime
|
||||
/// <c>KLJOGJHBONK.INHMKKAJJKO(string)</c>) returns ONE
|
||||
/// <c>ICOFKEGOGOD</c> ImageDTO, not a list; a bare array crashes its
|
||||
/// reader. Not found (or private + not the owner) → 404 so the promise
|
||||
/// rejects cleanly rather than delivering a wrong-shaped body.</summary>
|
||||
[HttpGet("api/images/v6")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ImagesV6([FromQuery] string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return NotFound();
|
||||
var pid = this.CurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p =>
|
||||
p.BlobName == name && p.DeletedAt == null &&
|
||||
(p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value)));
|
||||
if (photo is null) return NotFound();
|
||||
return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v6/{id}</c> — single photo by numeric id.
|
||||
/// The 2023-03-21 client builds the path with
|
||||
/// <c>String.Format("{0}v6/{1}", "api/images/", id)</c>
|
||||
/// (<c>KLJOGJHBONK.txt:2694-2698</c>) and passes verb 0 = GET into the
|
||||
/// request-builder ctor (<c>:2702 Move rdx, 0</c> → <c>:2708 Call
|
||||
/// 0x1830036A0</c>); no query fields are attached.
|
||||
///
|
||||
/// The issuing method <c>KLJOGJHBONK.HLANOFILAEO(System.Int64)</c> returns
|
||||
/// <c>FGLDKEJLAKB<IReadOnlyList<Int32>></c>, but that is a
|
||||
/// CLIENT-SIDE projection: a
|
||||
/// <c>Func<LGLCPNPJCEC, IReadOnlyList<Int32>></c>
|
||||
/// (<c>:2756</c>) reduces the wire object to its tagged-player ids. So the
|
||||
/// body must be ONE <c>LGLCPNPJCEC</c> object — an array breaks the reader —
|
||||
/// with the 12 keys its generated serializer registers
|
||||
/// (<c>IBILPLGNAJE.txt:819-1078</c>: Id, ImageName, PlayerId, RoomId,
|
||||
/// PlayerEventId, Accessibility, AccessibilityLocked, Type, CreatedAt,
|
||||
/// TaggedPlayerIds, CheerCount, CommentCount), all of which
|
||||
/// <see cref="BuildImageInfo"/> already emits.
|
||||
///
|
||||
/// Without this route the request fell through to
|
||||
/// <c>api/images/{*path}</c>, which rejects "123" as a non-image filename →
|
||||
/// 404 on every photo-detail open. Visibility matches the by-name lookup:
|
||||
/// public photos to anyone, private ones only to their uploader.</summary>
|
||||
[HttpGet("api/images/v6/{id:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ImageByIdV6(long id)
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p =>
|
||||
p.Id == id && p.DeletedAt == null &&
|
||||
(p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value)));
|
||||
if (photo is null) return NotFound();
|
||||
return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v2/named</c> — the watch's
|
||||
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
|
||||
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
|
||||
@@ -354,19 +437,84 @@ public class ImagesController(
|
||||
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v2/player/{accountId:long}")]
|
||||
[HttpGet("api/images/v3/player/{accountId:long}")]
|
||||
[HttpGet("api/images/v4/player/{accountId:long}")]
|
||||
[HttpGet("api/images/v5/player/{accountId:long}")]
|
||||
public async Task<IActionResult> GetByAccount(long accountId,
|
||||
[FromQuery] int take = 50, [FromQuery] int skip = 0)
|
||||
[FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] string? sort = null)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
skip = Math.Max(0, skip);
|
||||
var rows = await db.Photos
|
||||
var query = db.Photos
|
||||
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.AsQueryable();
|
||||
query = ParseSort(sort) switch
|
||||
{
|
||||
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
|
||||
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
|
||||
_ => query.OrderByDescending(p => p.CreatedAt),
|
||||
};
|
||||
|
||||
var rows = await query
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
|
||||
}
|
||||
|
||||
/// <summary>GET api/images/v{2-5}/room/{roomId} — a room's public
|
||||
/// photo gallery. The 2023 client sends <c>sort</c>/<c>filter</c> as
|
||||
/// ENUM NAMES (<c>?sort=CheerCount_Desc&filter=PublicOnly</c>),
|
||||
/// not ints — binding them as <c>int</c> makes [ApiController]
|
||||
/// auto-400 and the watch toasts "Could not show images for room".
|
||||
/// <c>filter</c> is accepted but unused: we only store public,
|
||||
/// non-deleted photos for this view anyway (PublicOnly semantics).</summary>
|
||||
[HttpGet("api/images/v2/room/{roomId:long}")]
|
||||
[HttpGet("api/images/v3/room/{roomId:long}")]
|
||||
[HttpGet("api/images/v4/room/{roomId:long}")]
|
||||
[HttpGet("api/images/v5/room/{roomId:long}")]
|
||||
public async Task<IActionResult> GetByRoom(
|
||||
long roomId,
|
||||
[FromQuery] int take = 50,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] string? sort = null,
|
||||
[FromQuery] string? filter = null)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
skip = Math.Max(0, skip);
|
||||
_ = filter;
|
||||
|
||||
var query = db.Photos
|
||||
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
|
||||
.AsQueryable();
|
||||
query = ParseSort(sort) switch
|
||||
{
|
||||
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
|
||||
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
|
||||
_ => query.OrderByDescending(p => p.CreatedAt),
|
||||
};
|
||||
|
||||
var rows = await query
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
|
||||
}
|
||||
|
||||
/// <summary>Sort selector shared by the album views. Accepts the
|
||||
/// 2020 client's ints (0/1/2) and the 2023 client's enum names —
|
||||
/// the names contain the metric ("CheerCount_Desc", "ViewCount_…");
|
||||
/// anything unrecognised falls back to newest-first.</summary>
|
||||
private static int ParseSort(string? sort)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sort)) return 0;
|
||||
if (int.TryParse(sort, out var n)) return n;
|
||||
if (sort.Contains("Cheer", StringComparison.OrdinalIgnoreCase)) return 1;
|
||||
if (sort.Contains("View", StringComparison.OrdinalIgnoreCase)) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
|
||||
/// to public so it shows up on feed.rec.net. The watch's "Share"
|
||||
/// button hits this; if the watch posts to a different URL we'll
|
||||
@@ -409,6 +557,51 @@ public class ImagesController(
|
||||
return Ok(new { deleted = id });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/{id}/report</c> — file a moderation
|
||||
/// report against a photo from the in-game photo viewer.
|
||||
/// <c>KLJOGJHBONK.IDAJEMBKAGF(System.Int64)</c> formats the path as
|
||||
/// <c>String.Format("{0}v1/{1}/report", "api/images/", id)</c>
|
||||
/// (<c>KLJOGJHBONK.txt:4010-4014</c>) and moves verb 2 = POST into the
|
||||
/// builder ctor (<c>:4021 Move rdx, 2</c>, host 1 at <c>:4020</c>).
|
||||
///
|
||||
/// The request carries NO fields and NO body — no category, no free text —
|
||||
/// and the reply is dispatched through <c>BNDIAONDFFF.KDOPJCNKOOK</c>
|
||||
/// (<c>:4031</c>), which never deserialises it: the call is
|
||||
/// fire-and-forget, so the client cannot tell a 404 from a 200 and every
|
||||
/// report was being dropped silently. Everything actionable therefore has
|
||||
/// to be derived server-side from the photo id, which is why the reported
|
||||
/// photo's uploader becomes the report target and the photo's room the
|
||||
/// context. Category 5 = "Other", matching the screenshare reporter's
|
||||
/// default for reports that arrive without one.</summary>
|
||||
[HttpPost("api/images/v1/{id:long}/report")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ReportPhoto(long id)
|
||||
{
|
||||
var reporter = this.RequireCurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id && p.DeletedAt == null);
|
||||
if (photo is null) return NotFound();
|
||||
|
||||
// ReportEntity has no TargetPhotoId column, so the photo id + blob name
|
||||
// ride in the message text the same way club reports carry "[club {id}]"
|
||||
// — a moderator needs the actual image to act on the report.
|
||||
var message = $"[photo {photo.Id} {photo.BlobName}] reported from the in-game photo viewer";
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = reporter,
|
||||
TargetPlayerId = photo.UploaderPlayerId,
|
||||
RoomId = photo.RoomId,
|
||||
Category = 5,
|
||||
Message = message[..Math.Min(1000, message.Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"[images] photo {PhotoId} reported by {Reporter} (uploader {Uploader}, room {RoomId})",
|
||||
photo.Id, reporter, photo.UploaderPlayerId, photo.RoomId);
|
||||
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
// ── Legacy v1 ops (real persistence, was Ack-only stubs) ─────────
|
||||
|
||||
public sealed class CheerImageRequest
|
||||
@@ -417,22 +610,48 @@ public class ImagesController(
|
||||
public long PhotoId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BulkCheeredRequest
|
||||
{
|
||||
public List<long>? SavedImageIds { get; set; }
|
||||
public List<long>? ImageIds { get; set; }
|
||||
}
|
||||
/// <summary>Field names the cheer-state batch accepts. The 2023-03-21
|
||||
/// client sends the singular <c>"id"</c>
|
||||
/// (<c>KLJOGJHBONK.txt:3763 Move rdx, "id"</c>); the older spellings are
|
||||
/// kept so the 2020.12 watch and our own tooling keep working.</summary>
|
||||
private static readonly string[] CheerBulkIdKeys =
|
||||
{ "id", "ids", "SavedImageId", "SavedImageIds", "ImageId", "ImageIds" };
|
||||
|
||||
/// <summary>GET — and POST once the id list reaches 100 — for
|
||||
/// <c>api/images/v{4,5}/cheered/bulk</c>: the caller's cheer state for a
|
||||
/// batch of photos (the hearts on the photo feed).
|
||||
///
|
||||
/// VERB: the client picks it at runtime.
|
||||
/// <c>KLJOGJHBONK.JCLGICHPPGB(IEnumerable<Int64>)</c> calls
|
||||
/// <c>ALHIJCJOLCB.JIECAFGCODK(count, 100)</c> and moves the result straight
|
||||
/// into the request-builder's verb register
|
||||
/// (<c>KLJOGJHBONK.txt:3747 Call ALHIJCJOLCB.JIECAFGCODK</c> →
|
||||
/// <c>:3755 Move rdx, rdi</c> → <c>:3753 Move r9,
|
||||
/// "api/images/v5/cheered/bulk"</c>) — GET (0) for short lists, POST (2) for
|
||||
/// long ones. Registering POST only meant the common case fell into the
|
||||
/// <c>api/images/{*path}</c> byte catch-all and 404'd, so cheer state never
|
||||
/// loaded.
|
||||
///
|
||||
/// REQUEST: one field, <c>"id"</c>, added via
|
||||
/// <c>BNDIAONDFFF.AFGEDDANEKP("id", ids)</c> (<c>KLJOGJHBONK.txt:3763</c>).
|
||||
/// The builder emits fields as query values on GET and form values on POST —
|
||||
/// never as a JSON body — so the ids are read straight off the request
|
||||
/// instead of through <c>[FromBody]</c>, which would 415 the form POST
|
||||
/// before the action ran.
|
||||
///
|
||||
/// RESPONSE: <c>List<LAKGLIDCEDE></c>, i.e.
|
||||
/// <c>[{"SavedImageId":Int64,"IsCheered":Boolean}]</c> — key literals in the
|
||||
/// generated serializer at <c>KJEIGGBIIHP.txt:215,242</c>. One entry per
|
||||
/// requested id so the client's cache has no holes.</summary>
|
||||
[HttpGet("api/images/v4/cheered/bulk")]
|
||||
[HttpGet("api/images/v5/cheered/bulk")]
|
||||
[HttpPost("api/images/v4/cheered/bulk")]
|
||||
[HttpPost("api/images/v5/cheered/bulk")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
|
||||
public async Task<IActionResult> CheeredBulk()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new())
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
var ids = await ReadCheerBulkIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var cheered = await db.Cheers
|
||||
@@ -447,6 +666,82 @@ public class ImagesController(
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Collect the cheer-state batch ids from wherever this client
|
||||
/// put them: query values on the GET path, form values on the POST path
|
||||
/// (both produced by <c>BNDIAONDFFF</c>'s field list), or a JSON body for
|
||||
/// non-game callers. Repeated fields and comma-joined values are both
|
||||
/// accepted because the builder's query assembler emits one
|
||||
/// <c>&id=</c> per element while our own admin tooling sends a single
|
||||
/// comma-separated value.</summary>
|
||||
private async Task<List<long>> ReadCheerBulkIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
|
||||
void Collect(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return;
|
||||
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
|
||||
foreach (var key in CheerBulkIdKeys)
|
||||
foreach (var value in Request.Query[key])
|
||||
Collect(value);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in CheerBulkIdKeys)
|
||||
foreach (var value in form[key])
|
||||
Collect(value);
|
||||
}
|
||||
else if (Request.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body, cancellationToken: HttpContext.RequestAborted);
|
||||
Request.Body.Position = 0;
|
||||
CollectJsonIds(doc.RootElement, Collect);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not JSON — the query/form pass above is authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
return ids.Distinct().Take(500).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Pull ids out of a JSON body that is either a bare array of
|
||||
/// numbers or an object keyed by one of <see cref="CheerBulkIdKeys"/>.</summary>
|
||||
private static void CollectJsonIds(JsonElement root, Action<string?> collect)
|
||||
{
|
||||
switch (root.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in root.EnumerateArray()) CollectJsonIds(item, collect);
|
||||
break;
|
||||
case JsonValueKind.Object:
|
||||
foreach (var key in CheerBulkIdKeys)
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (!string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
CollectJsonIds(prop.Value, collect);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
collect(root.GetRawText());
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
collect(root.GetString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
|
||||
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
|
||||
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
|
||||
@@ -504,6 +799,16 @@ public class ImagesController(
|
||||
public long ImageId { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public bool IsPublic { get; set; }
|
||||
|
||||
/// <summary>What the 2023-03-21 client actually sends:
|
||||
/// 0 = Private, 1 = Public, 2 = FriendsOnly. It never sends
|
||||
/// <see cref="IsPublic"/>, so reading only that flag left every photo
|
||||
/// private no matter what the player picked.</summary>
|
||||
public int? Accessibility { get; set; }
|
||||
|
||||
/// <summary>Resolved visibility: the enum when present, else the
|
||||
/// legacy boolean.</summary>
|
||||
public bool ResolvedIsPublic => Accessibility is int a ? a == 1 : IsPublic;
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/modifyaccessibility</c> — flip
|
||||
@@ -516,7 +821,8 @@ public class ImagesController(
|
||||
[FromForm(Name = "PhotoId")] long? photoIdForm,
|
||||
[FromForm(Name = "ImageId")] long? imageIdForm,
|
||||
[FromForm(Name = "ImageName")] string? imageNameForm,
|
||||
[FromForm(Name = "IsPublic")] bool? isPublicForm)
|
||||
[FromForm(Name = "IsPublic")] bool? isPublicForm,
|
||||
[FromForm(Name = "Accessibility")] int? accessibilityForm)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photoId = body?.PhotoId ?? body?.ImageId
|
||||
@@ -524,7 +830,12 @@ public class ImagesController(
|
||||
var imageName = body?.ImageName ?? imageNameForm;
|
||||
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
|
||||
return BadRequest(new { error = "missing photoId" });
|
||||
var isPublic = body?.IsPublic ?? isPublicForm ?? true;
|
||||
// Accessibility (0=Private, 1=Public, 2=FriendsOnly) is what the 2023
|
||||
// client sends; IsPublic is the older boolean.
|
||||
var accessibility = body?.Accessibility ?? accessibilityForm;
|
||||
var isPublic = accessibility is int a
|
||||
? a == 1
|
||||
: body?.IsPublic ?? isPublicForm ?? true;
|
||||
|
||||
var photo = photoId > 0
|
||||
? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId)
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.IncentivizedReferrals;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/incentivizedreferrals")]
|
||||
public class IncentivizedReferralsController(DorkNetDbContext db, LevelService level) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("")]
|
||||
[HttpGet("progress")]
|
||||
public async Task<IActionResult> Progress()
|
||||
{
|
||||
var referralCount = await db.PlayerSettings
|
||||
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
|
||||
var claimed = await db.PlayerSettings
|
||||
.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
|
||||
return Ok(new
|
||||
{
|
||||
ReferralCount = referralCount,
|
||||
RequiredReferralCount = 1,
|
||||
CanClaim = referralCount >= 1 && !claimed,
|
||||
Claimed = claimed,
|
||||
RewardCurrencyType = 2,
|
||||
RewardCurrency = 250,
|
||||
});
|
||||
}
|
||||
|
||||
// Client contract: the claim response deserializes as a top-level
|
||||
// List<RewardSelection> where each entry is
|
||||
// { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
|
||||
// AFEFBIKADAP/GLOFCEJBIGB). Returning a {Success,...} object crashes the
|
||||
// strict array reader. When there's nothing to grant, return an empty
|
||||
// array — a valid empty list, not an error object.
|
||||
[HttpPost("claim")]
|
||||
public async Task<IActionResult> Claim()
|
||||
{
|
||||
var referralCount = await db.PlayerSettings
|
||||
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
|
||||
var alreadyClaimed = await db.PlayerSettings
|
||||
.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
|
||||
if (referralCount < 1 || alreadyClaimed)
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral");
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = "referral:reward_claimed",
|
||||
Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// One granted reward selection: 250 of currency type 2.
|
||||
return Ok(new[]
|
||||
{
|
||||
new { rewardSelectionId = unchecked(Me * 397L + 2), rewardType = "Currency" },
|
||||
});
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.InfluencerPartnerProgram;
|
||||
|
||||
[ApiController]
|
||||
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
// 2023 client influencer API (RecNet.Runtime IBEEFFGHGND) — REAL wire
|
||||
// shapes verified against the 2023.03.21 ISIL decompile:
|
||||
//
|
||||
// influencers → OBJECT {"InfluencerIds":[...],"ContinuationToken":null}
|
||||
// - IBEEFFGHGND.txt:1612 — response callback is
|
||||
// Func<FEAJJMACBMF, FGLDKEJLAKB<List<Int32>>>, i.e. the body is
|
||||
// deserialized as DTO object FEAJJMACBMF, NOT a bare array
|
||||
// (a bare array crashes the strict reader:
|
||||
// "FGNDDNDAFCI: expected:'{', actual:'['").
|
||||
// - FLEDNKHKIND.txt:215-265 — FEAJJMACBMF's generated reader
|
||||
// registers keys "InfluencerIds" and "ContinuationToken"
|
||||
// (Pascal/camel/lower probes, so camelCase is accepted).
|
||||
// - IBEEFFGHGND_NestedType___c.txt:318-358
|
||||
// (<GetAllInfluencersInternal>b__12_0) — a non-null
|
||||
// ContinuationToken triggers a recursive next-page fetch, so it
|
||||
// must be null once the single page is exhausted.
|
||||
// - IBEEFFGHGND.txt:1547-1575 (OELDHDEIFGP) — client sends
|
||||
// take=1000 + continuationToken query params.
|
||||
[HttpGet("api/influencerpartnerprogram/influencers")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Influencers([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 1000);
|
||||
var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
|
||||
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
|
||||
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
|
||||
.Distinct()
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
InfluencerIds = creatorIds.Select(id => (int)id).ToArray(),
|
||||
ContinuationToken = (string?)null,
|
||||
});
|
||||
}
|
||||
|
||||
// influencer / myinfluencer → bare Int32 scalar, "no influencer" = 0:
|
||||
// - IBEEFFGHGND.txt:598/951 — both pipelines parse the body as Int32
|
||||
// and convert via Func<Int32, Nullable<Int32>>.
|
||||
// - IBEEFFGHGND_NestedType___c.txt:61-111
|
||||
// (<GetMySupportedInfluencer>b__8_0) — the converter maps
|
||||
// id == 0 → null, else (int?)id. So the correct "none" response is
|
||||
// the literal number 0. A "null"/empty body throws
|
||||
// CHGIJBGGJAG "Response was empty" (Player.log:1947-2031).
|
||||
[HttpGet("api/influencerpartnerprogram/influencer")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null)
|
||||
{
|
||||
var playerId = accountId is long id
|
||||
? await db.Players.Where(p => p.Id == id).Select(p => (long?)p.Id).FirstOrDefaultAsync()
|
||||
: await db.Players.Where(p => p.Username == username).Select(p => (long?)p.Id).FirstOrDefaultAsync();
|
||||
return Content(playerId is long pid ? ((int)pid).ToString() : "0", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("api/influencerpartnerprogram/myinfluencer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MyInfluencer()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var setting = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
|
||||
return Content(
|
||||
setting is not null && long.TryParse(setting.Value, out var influencerId)
|
||||
? ((int)influencerId).ToString()
|
||||
: "0",
|
||||
"application/json");
|
||||
}
|
||||
|
||||
[HttpPost("api/influencerpartnerprogram/support")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Support([FromForm] long? influencerAccountId, [FromForm] string? username)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var influencer = influencerAccountId is long id
|
||||
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
|
||||
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
|
||||
if (influencer is null) return NotFound();
|
||||
|
||||
var setting = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
|
||||
if (setting is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "influencer:supported",
|
||||
Value = influencer.Id.ToString(),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.Value = influencer.Id.ToString();
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
// Client return type is LDGADANDBIO (the fire-and-forget POST response
|
||||
// handle, same as PlayerCheer create) — it only checks success and
|
||||
// ignores the body, so no typed shape is required here.
|
||||
return Ok(new { Success = true, Influencer = (int)influencer.Id });
|
||||
}
|
||||
|
||||
[HttpPost("api/influencerpartnerprogram/remove")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Remove()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == me && s.Key == "influencer:supported")
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public class InventionsController(
|
||||
|
||||
// ── Browse ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/inventions/v1/featured")]
|
||||
[HttpGet("api/inventions/v3/popular")]
|
||||
public async Task<ActionResult> Popular([FromQuery] int take = 50)
|
||||
{
|
||||
@@ -52,6 +53,82 @@ public class InventionsController(
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/toptoday</c> — the client fires
|
||||
/// this as its own no-arg shelf query
|
||||
/// (OEGFNFEAAGO_NestedType___c_NestedType___GetTopInventionsToday_b__43_0_d.txt:147,
|
||||
/// verb 0) and shows it NEXT TO the featured shelf, so aliasing it onto
|
||||
/// the all-time popular query rendered the same twelve tiles twice.
|
||||
/// Ranked by cheers received in the last 24h, then backfilled with
|
||||
/// all-time popular so a quiet day still fills the shelf.</summary>
|
||||
[HttpGet("api/inventions/v1/toptoday")]
|
||||
public async Task<ActionResult> TopToday([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var since = DateTime.UtcNow.AddDays(-1);
|
||||
var hot = await db.Cheers
|
||||
.Where(c => c.TargetInventionId > 0 && c.CheeredAt >= since)
|
||||
.GroupBy(c => c.TargetInventionId)
|
||||
.Select(g => new { InventionId = g.Key, Score = g.Count() })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
var order = hot.Select(h => h.InventionId).ToList();
|
||||
var hits = order.Count == 0
|
||||
? new List<InventionEntity>()
|
||||
: await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished && order.Contains(i.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var ranked = order
|
||||
.Select(id => hits.FirstOrDefault(r => r.Id == id))
|
||||
.Where(r => r is not null)
|
||||
.Select(r => r!)
|
||||
.ToList();
|
||||
|
||||
if (ranked.Count < take)
|
||||
{
|
||||
var have = ranked.Select(r => r.Id).ToList();
|
||||
var filler = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished && !have.Contains(i.Id))
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.Take(take - ranked.Count)
|
||||
.ToListAsync();
|
||||
ranked.AddRange(filler);
|
||||
}
|
||||
return Ok(ranked.Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/featureddormskins</c> — separate
|
||||
/// shelf in the client
|
||||
/// (OEGFNFEAAGO_NestedType___c_NestedType___GetFeaturedDormSkins_b__45_0_d.txt:147,
|
||||
/// verb 0, no params) which previously returned generic popular
|
||||
/// inventions. <see cref="InventionEntity"/> has no dorm-skin column, so
|
||||
/// the marker lives in <see cref="InventionEntity.TagsCsv"/> — see
|
||||
/// <see cref="IsDormSkinTags"/>. The filter runs in memory because
|
||||
/// TagsCsv is a packed list no provider can split.</summary>
|
||||
[HttpGet("api/inventions/v1/featureddormskins")]
|
||||
public async Task<ActionResult> FeaturedDormSkins([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "")
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Where(i => IsDormSkinTags(i.TagsCsv)).Take(take).Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>Dorm-skin marker. There is no IsDormSkin column on
|
||||
/// <see cref="InventionEntity"/>, so a tag decides: any tag that reads
|
||||
/// "dormskin" / "dorm skin" (case- and space-insensitive).</summary>
|
||||
private static bool IsDormSkinTags(string? tagsCsv) =>
|
||||
!string.IsNullOrEmpty(tagsCsv)
|
||||
&& tagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Any(t => t.Replace(" ", string.Empty)
|
||||
.Equals("dormskin", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
[HttpGet("api/inventions/v3/saved")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Saved([FromQuery] int take = 50)
|
||||
@@ -77,9 +154,20 @@ public class InventionsController(
|
||||
[Authorize]
|
||||
public Task<ActionResult> Mine() => Saved(100);
|
||||
|
||||
/// <summary>The 2023 client sends value + skip + take on every store
|
||||
/// search (OEGFNFEAAGO.txt:10805/10820/10832 — three AFGEDDANEKP pairs
|
||||
/// on a verb-0 request). Ignoring skip/take pinned every page to the
|
||||
/// first 50 rows, so scrolling the store re-showed the same tiles.</summary>
|
||||
[HttpGet("api/inventions/v1/search")]
|
||||
public async Task<ActionResult> Search([FromQuery] string value = "")
|
||||
[HttpGet("api/inventions/v2/search")]
|
||||
public async Task<ActionResult> Search(
|
||||
[FromQuery] string value = "",
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 100)
|
||||
{
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
@@ -108,7 +196,8 @@ public class InventionsController(
|
||||
.Where(i => !i.IsDeleted && i.IsPublished
|
||||
&& creators.Contains(i.CreatorPlayerId))
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.Take(50)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(byCreator.Select(ToWire));
|
||||
}
|
||||
@@ -119,21 +208,167 @@ public class InventionsController(
|
||||
&& (i.Name.ToLower().Contains(v)
|
||||
|| i.TagsCsv.ToLower().Contains(v)))
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.Take(50)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record InventionBatchRequest(List<long> InventionIds);
|
||||
// ── Id-list endpoints (runtime GET/POST) ─────────────────────────────
|
||||
//
|
||||
// The 2023 client picks the verb at RUNTIME for every id-list route:
|
||||
// ALHIJCJOLCB.JIECAFGCODK (IsilDump/RecNet.Runtime/ALHIJCJOLCB.txt:3198)
|
||||
// is `count < limit ? GET : POST` with limit=100. On a non-GET the
|
||||
// key/value pairs move out of the query string and into an
|
||||
// HTTPUrlEncodedForm body — BNDIAONDFFF.txt:2971-3010 branches on the
|
||||
// verb (GET → "?a=b&…", otherwise → HTTPUrlEncodedForm). So the POST
|
||||
// actions must NOT declare a complex parameter, or [ApiController]
|
||||
// infers [FromBody] and answers 415 before the handler runs; we read
|
||||
// query + form by hand instead.
|
||||
//
|
||||
// Field names are literal:
|
||||
// "id" → v2/batch (OEGFNFEAAGO_NestedType_LANMKGKDNDC.txt:228)
|
||||
// "id" → fromcreators (…CDILMPCKMEO…GetInventionsByCreators_b__0_d.txt:330)
|
||||
// "ids" → dormskinsfromids (OEGFNFEAAGO.txt:5913)
|
||||
|
||||
[HttpPost("api/inventions/v1/batch")]
|
||||
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
||||
/// <summary>Body shape for non-2023 callers, which still send a JSON
|
||||
/// <c>{"InventionIds":[…]}</c> envelope rather than repeated fields.</summary>
|
||||
private sealed class InventionIdsBody
|
||||
{
|
||||
if (req?.InventionIds is null || req.InventionIds.Count == 0)
|
||||
return Ok(Array.Empty<object>());
|
||||
var ids = req.InventionIds.Take(200).ToList();
|
||||
public List<long>? InventionIds { get; set; }
|
||||
public List<long>? Ids { get; set; }
|
||||
public List<long>? Id { get; set; }
|
||||
}
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions IdsJson =
|
||||
new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
/// <summary>Gather ids from the query string, the url-encoded form and
|
||||
/// (as a fallback) a JSON id envelope. See the block comment above for
|
||||
/// why all three have to be accepted on one route.</summary>
|
||||
private async Task<List<long>> CollectIdsAsync(params string[] keys)
|
||||
{
|
||||
var ids = new List<long>();
|
||||
|
||||
void Take(string? raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return;
|
||||
foreach (var part in raw.Split(
|
||||
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
foreach (var v in Request.Query[key]) Take(v);
|
||||
if (form is not null)
|
||||
foreach (var v in form[key]) Take(v);
|
||||
}
|
||||
|
||||
if (ids.Count == 0 && form is null
|
||||
&& (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) ?? false))
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await System.Text.Json.JsonSerializer
|
||||
.DeserializeAsync<InventionIdsBody>(Request.Body, IdsJson);
|
||||
foreach (var list in new[] { body?.InventionIds, body?.Ids, body?.Id })
|
||||
{
|
||||
if (list is not null) ids.AddRange(list.Where(id => id > 0));
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Not an id envelope — treat as "no ids" rather than 400.
|
||||
}
|
||||
}
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Read one scalar field from the query string, falling back to
|
||||
/// the url-encoded form body (POST variants — see the block comment
|
||||
/// above).</summary>
|
||||
private async Task<string?> ReadFieldAsync(string key)
|
||||
{
|
||||
var fromQuery = Request.Query[key].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(fromQuery)) return fromQuery;
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var fromForm = form[key].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(fromForm)) return fromForm;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(string key)
|
||||
=> int.TryParse(await ReadFieldAsync(key), out var v) ? v : null;
|
||||
|
||||
private async Task<long?> ReadLongFieldAsync(string key)
|
||||
=> long.TryParse(await ReadFieldAsync(key), out var v) ? v : null;
|
||||
|
||||
[HttpGet("api/inventions/v1/batch")]
|
||||
[HttpGet("api/inventions/v2/batch")]
|
||||
[HttpPost("api/inventions/v1/batch")]
|
||||
[HttpPost("api/inventions/v2/batch")]
|
||||
public async Task<ActionResult> Batch()
|
||||
=> await BatchIdsAsync(await CollectIdsAsync("id", "ids", "InventionIds"));
|
||||
|
||||
/// <summary><c>api/inventions/v1/dormskinsfromids</c> — the client
|
||||
/// deserialises a BARE <c>List<Int64></c> here, not a list of
|
||||
/// invention objects: the continuation at OEGFNFEAAGO.txt:5941 is typed
|
||||
/// <c>FGLDKEJLAKB<List`1<Int64>></c> and is then projected by a
|
||||
/// <c>Func<List<Int64>, List<Int64>></c> (:5966). The reply is the
|
||||
/// SUBSET of the posted ids that are dorm skins; aliasing this onto the
|
||||
/// generic batch handler fed objects to a number reader and killed
|
||||
/// dorm-skin filtering outright.</summary>
|
||||
[HttpGet("api/inventions/v1/dormskinsfromids")]
|
||||
[HttpPost("api/inventions/v1/dormskinsfromids")]
|
||||
public async Task<ActionResult> DormSkinsFromIds()
|
||||
{
|
||||
var ids = await CollectIdsAsync("ids", "id", "InventionIds");
|
||||
if (ids.Count == 0) return Ok(Array.Empty<long>());
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
|
||||
.Select(i => new { i.Id, i.TagsCsv })
|
||||
.ToListAsync();
|
||||
return Ok(rows.Where(r => IsDormSkinTags(r.TagsCsv)).Select(r => r.Id).ToList());
|
||||
}
|
||||
|
||||
private async Task<ActionResult> BatchIdsAsync(IReadOnlyCollection<long> requestedIds)
|
||||
{
|
||||
if (requestedIds.Count == 0)
|
||||
return Ok(Array.Empty<object>());
|
||||
var ids = requestedIds.Take(200).ToList();
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/fromcreators")]
|
||||
[HttpPost("api/inventions/v1/fromcreators")]
|
||||
public async Task<ActionResult> FromCreators()
|
||||
{
|
||||
// Only the literal "id" key carries creator ids. The previous
|
||||
// SelectMany-over-every-query-field also swallowed the client's
|
||||
// "skip"/"take" pairs, so take=100 quietly added player 100's
|
||||
// inventions to the shelf.
|
||||
var creatorIds = await CollectIdsAsync("id");
|
||||
if (creatorIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var skip = Math.Max(0, await ReadIntFieldAsync("skip") ?? 0);
|
||||
var take = Math.Clamp(await ReadIntFieldAsync("take") ?? 100, 1, 200);
|
||||
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId))
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
@@ -152,19 +387,59 @@ public class InventionsController(
|
||||
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize<T></c>
|
||||
/// and surfaced as "Failed to get tags: Malformed Response"
|
||||
/// (output_log.txt:1602+1639) — and that bubbles up as the
|
||||
/// dorm-load destabilisation we've been hunting.</summary>
|
||||
/// dorm-load destabilisation we've been hunting.
|
||||
///
|
||||
/// The 2023 DTO reads a THIRD list, <c>TrendingFilters</c>
|
||||
/// (HMDONHHKKGA.txt:099/169), which we never sent — callers projecting that
|
||||
/// list got an empty filter row. Popular/Trending are now derived from the
|
||||
/// tags actually in use (<see cref="InventionEntity.TagsCsv"/>) rather than
|
||||
/// echoing the pinned array three times; the curated list is the fallback
|
||||
/// so a fresh server still shows filters. The 2020.12 watch's
|
||||
/// <c>AEBEPCMAABC</c> reads only Pinned/Popular, so the extra key is
|
||||
/// inert there.</summary>
|
||||
[HttpGet("api/inventions/v1/tagfilters")]
|
||||
public IActionResult TagFilters()
|
||||
public async Task<IActionResult> TagFilters()
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
string[] pinned =
|
||||
[
|
||||
"sport", "game", "vehicle", "weapon", "decor", "tool",
|
||||
"art", "music", "puzzle", "combat", "build", "race",
|
||||
};
|
||||
];
|
||||
|
||||
// TagsCsv is a packed list no provider can split, so the frequency count
|
||||
// runs in memory over a bounded window of the most recently touched
|
||||
// tagged inventions.
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "")
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Select(i => new { i.TagsCsv, i.UpdatedAt })
|
||||
.Take(2000)
|
||||
.ToListAsync();
|
||||
|
||||
static IEnumerable<string> SplitTags(string csv) => csv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
static List<string> Rank(IEnumerable<string> tags) => tags
|
||||
.GroupBy(t => t, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.Key)
|
||||
.Take(12)
|
||||
.ToList();
|
||||
|
||||
var since = DateTime.UtcNow.AddDays(-7);
|
||||
var popular = Rank(rows.SelectMany(r => SplitTags(r.TagsCsv)));
|
||||
var trending = Rank(rows.Where(r => r.UpdatedAt >= since)
|
||||
.SelectMany(r => SplitTags(r.TagsCsv)));
|
||||
|
||||
if (popular.Count == 0) popular = [.. pinned];
|
||||
if (trending.Count == 0) trending = popular;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
PinnedFilters = tags,
|
||||
PopularFilters = tags,
|
||||
PinnedFilters = pinned,
|
||||
PopularFilters = popular,
|
||||
TrendingFilters = trending,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,6 +560,59 @@ public class InventionsController(
|
||||
return Ok(ToWire(i));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v1/trialInvention</c> — start a free
|
||||
/// trial of a store invention, spawned for the window returned by
|
||||
/// <c>trialInvention/duration</c>. Hosted here — not in
|
||||
/// StorefrontsController — so it can reuse the invention wire builder.
|
||||
/// We record the trial start so it's an auditable action.
|
||||
///
|
||||
/// TWO wire corrections. (1) The id is a FORM field, not a query param:
|
||||
/// DCFKEFHJAGC.txt:9037 sets verb 2 (POST) and :9050 adds the pair via
|
||||
/// <c>AFGEDDANEKP("inventionId", …)</c>, and on a non-GET those pairs go
|
||||
/// into an HTTPUrlEncodedForm body (see the id-list block comment above), so
|
||||
/// <c>[FromQuery]</c> bound 0 and every trial 404'd. (2) The reply is the
|
||||
/// Status/Invention/InventionVersion envelope: the continuation at
|
||||
/// DCFKEFHJAGC.txt:9086 is a
|
||||
/// <c>Func<BDNCJIPHHOK, FGLDKEJLAKB<IFJONDCAKKM>></c> which
|
||||
/// projects <c>.Invention</c> — a bare invention left that null.</summary>
|
||||
[HttpPost("api/storefronts/v1/trialInvention")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> TrialInvention()
|
||||
{
|
||||
var inventionId = await ReadLongFieldAsync("inventionId") ?? 0;
|
||||
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (i is null) return NotFound();
|
||||
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
|
||||
return Forbid();
|
||||
|
||||
if (CurrentPlayerIdOrNull is long me)
|
||||
{
|
||||
var key = $"invention:trial:{inventionId}";
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == key);
|
||||
if (row is null)
|
||||
db.PlayerSettings.Add(new Data.Entities.PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me, Key = key, Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
else
|
||||
row.Value = DateTime.UtcNow.ToString("O");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(await EnvelopeAsync(i));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/details</c> — the ONLY key either
|
||||
/// client reads off this response is <c>Tags</c>: the 2023 continuation is
|
||||
/// <c>Action<OIABGAKJABE></c> (OEGFNFEAAGO.txt:6312) whose reader takes
|
||||
/// exactly one literal, "Tags" (DBAHPLOPFIO.txt:034), and the 2020.12 watch's
|
||||
/// HJPDBNLCGIB does the same. Each element is
|
||||
/// <c>{Tag:String, Type:Int32}</c> (PCMLHLIBLNJ.txt:038/057). Without the key
|
||||
/// the invention detail page's tag row was permanently empty. Invention +
|
||||
/// Versions stay on the wire — unknown members are ignored by both readers.
|
||||
/// <c>Type</c> is always 0: <see cref="SetTags"/> merges the client's
|
||||
/// AutoTags and CustomTags into one <see cref="InventionEntity.TagsCsv"/>
|
||||
/// column, so the auto/custom distinction is not recoverable without a
|
||||
/// second column.</summary>
|
||||
[HttpGet("api/inventions/v1/details")]
|
||||
public async Task<ActionResult> Details([FromQuery] long inventionId)
|
||||
{
|
||||
@@ -298,21 +626,33 @@ public class InventionsController(
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Tags = i.TagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => new { Tag = t, Type = 0 }),
|
||||
Invention = ToWire(i),
|
||||
Versions = versions.Select(ToVersionWire),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/personaldetails/{id}</c> — the caller's
|
||||
/// own relationship to the invention. Both clients read a single key here:
|
||||
/// the 2023 continuation is <c>FGLDKEJLAKB<CEAFHBOOBKL></c>
|
||||
/// (OEGFNFEAAGO.txt:8995) and CEAFHBOOBKL's reader takes only "IsCheering"
|
||||
/// (BCLCHNGENDE.txt:036); the 2020.12 watch's OEGPIPBKHCN is identical.
|
||||
/// Without it the detail page never lit the player's own cheer.</summary>
|
||||
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> PersonalDetails(long id)
|
||||
{
|
||||
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
|
||||
if (i is null) return NotFound();
|
||||
var pid = CurrentPlayerId;
|
||||
return Ok(new
|
||||
{
|
||||
IsCheering = await db.Cheers.AnyAsync(c =>
|
||||
c.FromPlayerId == pid && c.TargetInventionId == id),
|
||||
Invention = ToWire(i),
|
||||
CanEdit = i.CreatorPlayerId == CurrentPlayerId,
|
||||
CanEdit = i.CreatorPlayerId == pid,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -345,6 +685,14 @@ public class InventionsController(
|
||||
public string? ImageName { get; set; }
|
||||
public int InstantiationCost { get; set; }
|
||||
public int LightsCost { get; set; }
|
||||
/// <summary>RecNet.NewInventionRequestDTO carries <c>chipsCost</c> and
|
||||
/// <c>cloudVariablesCost</c> too (2023.06 dump.cs:1283258-1283259).
|
||||
/// <see cref="InventionVersionEntity"/> has no column for either, so
|
||||
/// they cannot be persisted — but binding them lets the save response
|
||||
/// echo back the budget the client just reported instead of two
|
||||
/// zeroes.</summary>
|
||||
public int ChipsCost { get; set; }
|
||||
public int CloudVariablesCost { get; set; }
|
||||
public int AiCost { get; set; }
|
||||
public long? CreationRoomId { get; set; }
|
||||
public string? InventionDataFilename { get; set; }
|
||||
@@ -356,6 +704,7 @@ public class InventionsController(
|
||||
/// save flow. v4 wraps the new invention plus its first version in
|
||||
/// a Status/Invention/InventionVersion object.</summary>
|
||||
[HttpPost("api/inventions/v4/save")]
|
||||
[HttpPost("api/inventions/v6/save")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
|
||||
{
|
||||
@@ -417,15 +766,33 @@ public class InventionsController(
|
||||
{
|
||||
Status = 0,
|
||||
Invention = ToWireV4(inv),
|
||||
InventionVersion = ToVersionWire(v1),
|
||||
InventionVersion = ToVersionWire(
|
||||
v1, Math.Max(0, req.ChipsCost), Math.Max(0, req.CloudVariablesCost)),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client names the blob field
|
||||
/// <c>inventionDataFilename</c> and sends four extra cost fields plus the
|
||||
/// creation room and referenced inventions. The record only had
|
||||
/// <c>BlobName</c>, which the client never sends, so the required-parameter
|
||||
/// check rejected every publish with 400 and no new invention version could
|
||||
/// ever be saved.</summary>
|
||||
public sealed record AddVersionRequest(
|
||||
long InventionId, string BlobName,
|
||||
int? InstantiationCost, int? LightsCost);
|
||||
long InventionId,
|
||||
string? BlobName,
|
||||
string? InventionDataFilename,
|
||||
int? InstantiationCost, int? LightsCost,
|
||||
int? ChipsCost, int? CloudVariablesCost, int? AiCost,
|
||||
long? CreationRoomId,
|
||||
List<long>? ReferencedInventions)
|
||||
{
|
||||
public string? Blob => !string.IsNullOrWhiteSpace(InventionDataFilename)
|
||||
? InventionDataFilename
|
||||
: BlobName;
|
||||
}
|
||||
|
||||
[HttpPost("api/inventions/v3/addversion")]
|
||||
[HttpPost("api/inventions/v4/addversion")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
|
||||
{
|
||||
@@ -433,24 +800,36 @@ public class InventionsController(
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
|
||||
var blobName = req.Blob;
|
||||
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest("missing inventionDataFilename");
|
||||
|
||||
var nextVer = inv.CurrentVersionNumber + 1;
|
||||
inv.CurrentBlobName = req.BlobName;
|
||||
inv.CurrentBlobName = blobName;
|
||||
inv.CurrentVersionNumber = nextVer;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
db.InventionVersions.Add(new InventionVersionEntity
|
||||
var version = new InventionVersionEntity
|
||||
{
|
||||
InventionId = inv.Id,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
VersionNumber = nextVer,
|
||||
BlobName = req.BlobName,
|
||||
BlobName = blobName,
|
||||
InstantiationCost = req.InstantiationCost ?? 0,
|
||||
LightsCost = req.LightsCost ?? 0,
|
||||
});
|
||||
};
|
||||
db.InventionVersions.Add(version);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
|
||||
// Envelope, not a bare invention — see EnvelopeAsync. The version we
|
||||
// just wrote is the one the client wants back, and the two cost fields
|
||||
// it sent are echoed because no column stores them.
|
||||
return Ok(new
|
||||
{
|
||||
Status = 0,
|
||||
Invention = ToWire(inv),
|
||||
InventionVersion = ToVersionWire(
|
||||
version, Math.Max(0, req.ChipsCost ?? 0), Math.Max(0, req.CloudVariablesCost ?? 0)),
|
||||
});
|
||||
}
|
||||
|
||||
// ── GET-based mutations (Core.Get pattern) ───────────────────────────
|
||||
@@ -488,7 +867,51 @@ public class InventionsController(
|
||||
}
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
/// <summary>Body shape for the POST form of <c>updateprice</c> —
|
||||
/// RecNet.UpdatePriceRequest, real field names preserved in the 2023.06
|
||||
/// dump (dump.cs:1283355-1283356). Settable properties + a parameterless
|
||||
/// ctor because <see cref="Binding.FormOrJsonModelBinder"/> constructs the
|
||||
/// instance itself.</summary>
|
||||
public sealed class UpdatePriceRequest
|
||||
{
|
||||
public long InventionId { get; set; }
|
||||
public int Price { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Both clients POST <c>updateprice</c> with a body; only the GET
|
||||
/// query form was registered, so ASP.NET answered 405 and re-pricing a paid
|
||||
/// invention always failed. 2023: OEGFNFEAAGO.txt:9850 sets verb 2 and
|
||||
/// :9870-9876 hands <c>JsonUtility.ToJson(UpdatePriceRequest)</c> to
|
||||
/// <c>FJLLPHFOOJJ</c>, which wraps it in a BestHTTP RawJsonForm
|
||||
/// (<c>application/json</c>, RawJsonForm.txt:40). December POSTs the same
|
||||
/// route form-encoded (2020.12 BBHENFCNLAB.txt:6480). The binder accepts
|
||||
/// both; the GET stays for callers that still use the query string.</summary>
|
||||
[HttpGet("api/inventions/v1/updateprice")]
|
||||
[Authorize]
|
||||
public Task<ActionResult> UpdatePrice(
|
||||
[FromQuery] long inventionId,
|
||||
[FromQuery] int price)
|
||||
=> ApplyPriceAsync(inventionId, price);
|
||||
|
||||
[HttpPost("api/inventions/v1/updateprice")]
|
||||
[Authorize]
|
||||
public Task<ActionResult> UpdatePricePost(
|
||||
[ModelBinder(typeof(Binding.FormOrJsonModelBinder))] UpdatePriceRequest req)
|
||||
=> ApplyPriceAsync(req.InventionId, req.Price);
|
||||
|
||||
private async Task<ActionResult> ApplyPriceAsync(long inventionId, int price)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
inv.Price = Math.Clamp(price, 0, 1000000);
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/delete")]
|
||||
@@ -504,13 +927,29 @@ public class InventionsController(
|
||||
inv.IsPublished = false;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
/// <summary>The 2023 publish call carries FOUR query params, not two:
|
||||
/// OEGFNFEAAGO.txt:9437/9449/9461/9473 add <c>inventionId</c>,
|
||||
/// <c>permissionLevel</c>, <c>accessibility</c> and a
|
||||
/// <c>Nullable<Int32></c> <c>price</c> — the wrapper signature is
|
||||
/// OEGFNFEAAGO.txt:9216. Dropping accessibility meant "publish as Private /
|
||||
/// Unlisted" still went fully public, and a paid publish lost its price.
|
||||
/// Accessibility constants: Private=0, Public=1, Unlisted=2
|
||||
/// (AEFFFPIJDHG.GAKJKOGJEEH, 2023.06 dump.cs:1282644-1282646). With no
|
||||
/// Accessibility column on the entity only the Private/not-Private half can
|
||||
/// be stored, so Unlisted currently behaves as Public. The param is
|
||||
/// NULLABLE on purpose — the 2020.12 watch sends only inventionId +
|
||||
/// permissionLevel (2020.12 BBHENFCNLAB.txt:6171), and a missing value must
|
||||
/// not be read as Private or December could never publish.</summary>
|
||||
[HttpGet("api/inventions/v3/publish")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Publish(
|
||||
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
|
||||
[FromQuery] long inventionId,
|
||||
[FromQuery] int permissionLevel,
|
||||
[FromQuery] int? accessibility,
|
||||
[FromQuery] int? price)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
@@ -518,13 +957,15 @@ public class InventionsController(
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
var perm = ClampInventionPermission(permissionLevel);
|
||||
if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)");
|
||||
var access = accessibility ?? 1 /* Public */;
|
||||
inv.GeneralPermission = perm;
|
||||
inv.IsPublished = true;
|
||||
inv.FirstPublishedAt ??= DateTime.UtcNow;
|
||||
inv.IsPublished = access != 0;
|
||||
if (inv.IsPublished) inv.FirstPublishedAt ??= DateTime.UtcNow;
|
||||
inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0);
|
||||
if (price.HasValue) inv.Price = Math.Clamp(price.Value, 0, 1000000);
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/unpublish")]
|
||||
@@ -540,7 +981,7 @@ public class InventionsController(
|
||||
inv.Permission = 0;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/download")]
|
||||
@@ -564,8 +1005,33 @@ public class InventionsController(
|
||||
|
||||
// ── POST mutations ───────────────────────────────────────────────────
|
||||
|
||||
public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags);
|
||||
/// <summary>The client sends <c>AutoTags</c> and <c>CustomTags</c> as JSON
|
||||
/// ARRAYS of strings. Binding them to <c>string</c> made deserialization
|
||||
/// throw, so every tag edit 400'd. <c>PlayerAddedTags</c> is kept as an
|
||||
/// alias for older callers. Field names are literal in the 2023.06 dump —
|
||||
/// RecNet.ModifyTagsRequest keeps real names (dump.cs:1283295-1283301) —
|
||||
/// and the body is a RAW JSON document
|
||||
/// (<c>JsonUtility.ToJson</c> → <c>BNDIAONDFFF.FJLLPHFOOJJ</c>,
|
||||
/// OEGFNFEAAGO.txt:3082-3088), so [FromBody] is right here.</summary>
|
||||
public sealed record SetTagsRequest(
|
||||
long InventionId,
|
||||
List<string>? AutoTags,
|
||||
List<string>? CustomTags,
|
||||
List<string>? PlayerAddedTags);
|
||||
|
||||
/// <summary>POST <c>api/inventions/v1/settags</c> — the reply is NOT an
|
||||
/// invention. The 2023 issuing method is typed
|
||||
/// <c>FGLDKEJLAKB<PNGLFHEAJIH></c> (OEGFNFEAAGO.txt:2744) and
|
||||
/// PNGLFHEAJIH exposes exactly two members — an int-backed result enum and
|
||||
/// <c>List<String></c> (PNGLFHEAJIH.txt:3/83) — read from the literals
|
||||
/// "Result" and "Tags" (ALJPHDEAHBK.txt:191/210). The 2020.12 watch is
|
||||
/// identical: NJMAEIPIOAP.PPGFHEDFBEA pulls "Result" then "Tags"
|
||||
/// (2020.12 NJMAEIPIOAP.txt:85/90) with the throwing Util.GetKey, so the
|
||||
/// old bare-invention reply killed every tag edit on December too.
|
||||
/// <c>Result = 0</c> is success: the enum's message formatter jump-tables on
|
||||
/// the value and case 0 returns "Success!" (PNGLFHEAJIH.txt:334-341).
|
||||
/// <c>Tags</c> is a flat string list — NOT the {Tag,Type} pairs
|
||||
/// <see cref="Details"/> returns.</summary>
|
||||
[HttpPost("api/inventions/v1/settags")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> SetTags([FromBody] SetTagsRequest req)
|
||||
@@ -574,16 +1040,39 @@ public class InventionsController(
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(',');
|
||||
inv.TagsCsv = combined;
|
||||
var tags = new[] { req.AutoTags, req.CustomTags, req.PlayerAddedTags }
|
||||
.Where(list => list is not null)
|
||||
.SelectMany(list => list!)
|
||||
.Select(t => (t ?? string.Empty).Trim())
|
||||
.Where(t => t.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
inv.TagsCsv = string.Join(',', tags);
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(new { Result = 0, Tags = tags });
|
||||
}
|
||||
|
||||
public sealed record CheerRequest(long InventionId);
|
||||
/// <summary>RecNet.CheerRequest carries a SECOND field — <c>Cheer</c>, the
|
||||
/// desired state — whose real name survives in the 2023.06 dump
|
||||
/// (dump.cs:1283338-1283342: <c>long InventionId</c> @0x10,
|
||||
/// <c>bool Cheer</c> @0x18). Both writes are visible in the ISIL: 2023 sets
|
||||
/// [rdi+16] and [rdi+24] before <c>JsonUtility.ToJson</c>
|
||||
/// (OEGFNFEAAGO.txt:11228-11233) and December does the same
|
||||
/// (2020.12 BBHENFCNLAB.txt:7624-7628) — its wrapper signature is
|
||||
/// <c>ANJHOOPIAKM(Int64, Boolean)</c> (BBHENFCNLAB.txt:7503). Ignoring the
|
||||
/// flag made un-cheering a no-op: the button toggled off in the UI and the
|
||||
/// cheer stayed on the invention forever.</summary>
|
||||
public sealed record CheerRequest(long InventionId, bool Cheer = true);
|
||||
|
||||
/// <summary>POST <c>api/inventions/v1/cheer</c> — answers with the
|
||||
/// Status/Invention/InventionVersion envelope like every other invention
|
||||
/// mutation: the 2023 method is <c>FGLDKEJLAKB<BDNCJIPHHOK></c>
|
||||
/// (OEGFNFEAAGO.txt:11065) and December's is
|
||||
/// <c>IPromise<AHEPPAEOLOD></c> (2020.12 BBHENFCNLAB.txt:7503). The old
|
||||
/// <c>{Id, CheerCount}</c> reply left Invention null on 2023 and threw
|
||||
/// KeyNotFoundException on December, so the tile never refreshed its
|
||||
/// count.</summary>
|
||||
[HttpPost("api/inventions/v1/cheer")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Cheer([FromBody] CheerRequest req)
|
||||
@@ -592,10 +1081,11 @@ public class InventionsController(
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
|
||||
// Idempotent: one cheer per (player, invention).
|
||||
// Idempotent in both directions: one cheer row per (player, invention).
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == pid && c.TargetInventionId == req.InventionId);
|
||||
if (existing is null)
|
||||
|
||||
if (req.Cheer && existing is null)
|
||||
{
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
@@ -611,12 +1101,29 @@ public class InventionsController(
|
||||
PushNotificationId.InventionModerationStateChanged,
|
||||
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
|
||||
}
|
||||
return Ok(new { inv.Id, inv.CheerCount });
|
||||
else if (!req.Cheer && existing is not null)
|
||||
{
|
||||
db.Cheers.Remove(existing);
|
||||
inv.CheerCount = Math.Max(0, inv.CheerCount - 1);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
public sealed record ReportInventionRequest(
|
||||
long InventionId, int? ReportCategory, string? Details);
|
||||
|
||||
/// <summary>POST <c>api/inventions/v1/report</c> — the reply is a
|
||||
/// success/message pair, not a status flag of our own invention. The 2023
|
||||
/// method is <c>FGLDKEJLAKB<PHMHCPEMABG></c> (OEGFNFEAAGO.txt:10917)
|
||||
/// and PHMHCPEMABG holds a Boolean + a String (PHMHCPEMABG.txt:3/23) read
|
||||
/// from the literals "Success"/"Message" (GBPDOLJBABB.txt:191/210); the
|
||||
/// 2020.12 watch's KLAMKCBENEA.PPGFHEDFBEA pulls the same two literals
|
||||
/// (2020.12 KLAMKCBENEA.txt:85/90). Our old <c>{Reported:true}</c> left
|
||||
/// Success at default false, so the report UI reported failure even though
|
||||
/// the row persisted. <c>Message</c> must be a string — December's
|
||||
/// Util.GetKey casts it — so send "" rather than null.</summary>
|
||||
[HttpPost("api/inventions/v1/report")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Report([FromBody] ReportInventionRequest req)
|
||||
@@ -634,9 +1141,225 @@ public class InventionsController(
|
||||
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Reported = true });
|
||||
return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
// ── Rec Room Studio remote run ───────────────────────────────────────
|
||||
|
||||
/// <summary>One uploaded blob as the client describes it: three strings.
|
||||
/// Wire keys are literal in the generated serializer — "Filename", "Hash",
|
||||
/// "OwnershipProof" (FLELPHJDLNG.txt:255/274/290, with camelCase and
|
||||
/// all-lowercase aliases registered alongside each). The backing type is
|
||||
/// <c>CFPEPOJAMHH/IFIBBJDIHJH</c>, three <c>System.String</c> accessors
|
||||
/// (CFPEPOJAMHH_NestedType_IFIBBJDIHJH.txt:3/25/47). Same triple the room
|
||||
/// save commit sends on <c>rooms/{id}/subrooms/{id}/data</c>; declared here
|
||||
/// so this controller binds its own body.</summary>
|
||||
public sealed class StudioBlobRef
|
||||
{
|
||||
public string? Filename { get; set; }
|
||||
public string? Hash { get; set; }
|
||||
public string? OwnershipProof { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Body of <c>remote-run/push-to-studio</c> — RecNet type
|
||||
/// <c>FNAGBPCAGJD</c>. Field order/types come from the request builder
|
||||
/// (EHIOLHBGODG.txt:045-070 writes +16 String, +24 Int64, +32 Int64,
|
||||
/// +40 String, +48/+56 the two blob refs, +64 a <c>Nullable<Int32></c>)
|
||||
/// and the property accessors on FNAGBPCAGJD.txt:3-141. The JSON key for
|
||||
/// each is literal in the generated serializer OFMNNCMPEPA.txt:535-698:
|
||||
/// <c>SessionId, RoomId, SubRoomId, UnityAssetId, RoomData, SubRoomData,
|
||||
/// SavedByAccountId</c> (camel/lower aliases registered too).</summary>
|
||||
public sealed class PushToStudioRequest
|
||||
{
|
||||
public string? SessionId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public string? UnityAssetId { get; set; }
|
||||
public StudioBlobRef? RoomData { get; set; }
|
||||
public StudioBlobRef? SubRoomData { get; set; }
|
||||
public int? SavedByAccountId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>remote-run/push-to-studio</c> — hands the room the
|
||||
/// player just uploaded to the paired Rec Room Studio session, WITHOUT
|
||||
/// committing it as the room's live save (that is the separate
|
||||
/// <c>rooms/{id}/subrooms/{id}/data</c> path; the client's two flows are
|
||||
/// sibling methods OGPDOMCNIFM.txt:271 vs :449).
|
||||
///
|
||||
/// <para><b>Verb.</b> POST. The request builder at EHIOLHBGODG.txt:411-417
|
||||
/// moves the route literal <c>"remote-run/push-to-studio"</c> into r9 and
|
||||
/// <c>2</c> (BestHTTP HTTPMethods.Post) into rdx before
|
||||
/// <c>BNDIAONDFFF..ctor(verb, host, route)</c>. No cmov — one verb only.
|
||||
/// The body is RAW JSON, not form fields: :433 hands the serialized DTO to
|
||||
/// <c>BNDIAONDFFF.FJLLPHFOOJJ</c> (RawJsonForm, <c>application/json</c>),
|
||||
/// so this binds via <see cref="Binding.FormOrJsonModelBinder"/> — the
|
||||
/// explicit [ModelBinder] is required or [ApiController] re-infers
|
||||
/// [FromBody] and 415s a form-shaped caller.</para>
|
||||
///
|
||||
/// <para><b>Response.</b> <c>FGLDKEJLAKB<CEELGOLBHIL></c>
|
||||
/// (EHIOLHBGODG.txt:217) — a single RemoteRunDetails object, EIGHT keys,
|
||||
/// all literal in its generated reader PPHLKNGCGOE.txt:599-786:
|
||||
/// <c>SessionId</c> (String), <c>RoomId</c> and <c>SubRoomId</c>
|
||||
/// (<c>Nullable<Int64></c> — CEELGOLBHIL.txt:25/51), then
|
||||
/// <c>UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename,
|
||||
/// SubRoomDataHash</c> (String). Note the response FLATTENS the two blob
|
||||
/// refs the request nests, and drops OwnershipProof. A failed/malformed
|
||||
/// reply surfaces the client's "Failed to push to Rec Room Studio" toast
|
||||
/// (EHIOLHBGODG.txt:445).</para>
|
||||
///
|
||||
/// <para><b>What it persists.</b> The pushed blobs were already stored by
|
||||
/// <c>/upload</c> (every FileType there inserts a
|
||||
/// <see cref="RoomDataBlobEntity"/>), so the push is a registration step:
|
||||
/// it stamps the sub-room save blob with the room + sub-room it belongs to
|
||||
/// — that is what makes it appear in the per-sub-room
|
||||
/// <c>datahistory</c>/"Restore to old version" list, which the FileType=6
|
||||
/// room-metadata upload path (RoomId=0, SubRoomId=null) cannot do on its
|
||||
/// own — writes the session id into
|
||||
/// <see cref="RoomEntity.StudioSessionId"/> (the column that exists for
|
||||
/// exactly this and is echoed in room details), records the pushed Unity
|
||||
/// asset id on the sub-room's <see cref="RoomSceneEntity"/>, and keeps the
|
||||
/// whole push under <c>remoterun:{SessionId}</c> in
|
||||
/// <see cref="PlayerSettingEntity"/> so the details survive for the paired
|
||||
/// Studio session to resolve. <see cref="RoomEntity.IsRoomLinkedToRecRoomStudio"/>
|
||||
/// is deliberately NOT flipped: it changes in-room MakerPen UI and the
|
||||
/// client has no unlink call, so a push must not one-way-toggle it.</para>
|
||||
///
|
||||
/// <para><b>Not implemented:</b> live relay of the details to a second
|
||||
/// logged-in session. The client's receive side
|
||||
/// (<c>EHIOLHBGODG.MCPBJLHFOBC(CEELGOLBHIL)</c> → the
|
||||
/// <c>Action<CEELGOLBHIL></c> that MBOJJFBIAGE.txt:366/978 subscribes)
|
||||
/// has no call site anywhere in the IsilDump, so the notification id that
|
||||
/// carries it cannot be established from the binary — inventing one would
|
||||
/// be a guess. The HTTP round-trip is complete and the push is durable
|
||||
/// either way.</para></summary>
|
||||
[HttpPost("remote-run/push-to-studio")]
|
||||
[HttpPost("roomserver/remote-run/push-to-studio")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> PushToStudio(
|
||||
[ModelBinder(typeof(Binding.FormOrJsonModelBinder))] PushToStudioRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
|
||||
var sessionId = Clamp((req.SessionId ?? string.Empty).Trim(), 100);
|
||||
if (sessionId.Length == 0)
|
||||
return BadRequest(new { Error = "missing SessionId" });
|
||||
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
|
||||
if (room is null) return NotFound();
|
||||
|
||||
// Same gate the room-save upload uses (StorageController.UploadRoomSave):
|
||||
// creator, admin, or an accepted CoOwner (Role 0).
|
||||
var canPush = room.CreatorPlayerId == pid
|
||||
|| await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin)
|
||||
|| await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == room.Id && r.PlayerId == pid && r.Accepted && r.Role == 0);
|
||||
if (!canPush) return Forbid();
|
||||
|
||||
var subRoomId = req.SubRoomId;
|
||||
var scene = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
|
||||
|
||||
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
|
||||
if (subRoomDataFilename.Length == 0)
|
||||
return BadRequest(new { Error = "missing SubRoomData.Filename" });
|
||||
|
||||
// The push names blobs the client uploaded moments ago; if the row is
|
||||
// absent the bytes are not on this server and Studio could never fetch
|
||||
// them. Fail loudly rather than answering with a filename that 404s.
|
||||
var subRoomBlob = await db.RoomDataBlobs
|
||||
.FirstOrDefaultAsync(b => b.BlobName == subRoomDataFilename);
|
||||
if (subRoomBlob is null)
|
||||
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
|
||||
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
|
||||
subRoomBlob.SubRoomId ??= subRoomId;
|
||||
|
||||
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
|
||||
if (roomDataFilename.Length > 0)
|
||||
{
|
||||
// FileType=6 (RoomMetadata) uploads land with RoomId=0 — the push
|
||||
// is the first point at which the owning room is known.
|
||||
var roomBlob = await db.RoomDataBlobs
|
||||
.FirstOrDefaultAsync(b => b.BlobName == roomDataFilename);
|
||||
if (roomBlob is null)
|
||||
return BadRequest(new { Error = "unknown RoomData.Filename" });
|
||||
if (roomBlob.RoomId == 0) roomBlob.RoomId = room.Id;
|
||||
}
|
||||
|
||||
var unityAssetId = Clamp((req.UnityAssetId ?? string.Empty).Trim(), 64);
|
||||
if (unityAssetId.Length == 0)
|
||||
{
|
||||
unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
|
||||
}
|
||||
else if (scene is not null && scene.StudioUnityAssetId != unityAssetId)
|
||||
{
|
||||
scene.StudioUnityAssetId = unityAssetId;
|
||||
scene.DataModifiedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
var roomDataHash = Clamp((req.RoomData?.Hash ?? string.Empty).Trim(), 128);
|
||||
var subRoomDataHash = Clamp((req.SubRoomData?.Hash ?? string.Empty).Trim(), 128);
|
||||
|
||||
room.StudioSessionId = sessionId;
|
||||
|
||||
var record = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
RoomId = room.Id,
|
||||
SubRoomId = subRoomId,
|
||||
UnityAssetId = unityAssetId,
|
||||
RoomDataFilename = roomDataFilename,
|
||||
RoomDataHash = roomDataHash,
|
||||
SubRoomDataFilename = subRoomDataFilename,
|
||||
SubRoomDataHash = subRoomDataHash,
|
||||
SavedByAccountId = req.SavedByAccountId ?? (int)pid,
|
||||
PushedAt = DateTime.UtcNow,
|
||||
});
|
||||
if (record.Length > 1024)
|
||||
{
|
||||
// PlayerSettingEntity.Value is varchar(1024); truncating JSON would
|
||||
// store an unparseable fragment, so drop the optional hashes first.
|
||||
record = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
RoomId = room.Id,
|
||||
SubRoomId = subRoomId,
|
||||
UnityAssetId = unityAssetId,
|
||||
RoomDataFilename = roomDataFilename,
|
||||
SubRoomDataFilename = subRoomDataFilename,
|
||||
SavedByAccountId = req.SavedByAccountId ?? (int)pid,
|
||||
PushedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
var key = $"remoterun:{sessionId}";
|
||||
var setting = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key);
|
||||
if (setting is null)
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid, Key = key, Value = record,
|
||||
});
|
||||
else
|
||||
setting.Value = record;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
RoomId = (long?)room.Id,
|
||||
SubRoomId = (long?)subRoomId,
|
||||
UnityAssetId = unityAssetId,
|
||||
RoomDataFilename = roomDataFilename,
|
||||
RoomDataHash = roomDataHash,
|
||||
SubRoomDataFilename = subRoomDataFilename,
|
||||
SubRoomDataHash = subRoomDataHash,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Trim a wire string to a column's length. Every string that
|
||||
/// reaches a MaxLength column here comes straight off the request.</summary>
|
||||
private static string Clamp(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max];
|
||||
|
||||
// ── Wire serializers ─────────────────────────────────────────────────
|
||||
|
||||
private static int ClampInventionPermission(int v) => v switch
|
||||
@@ -685,13 +1408,40 @@ public class InventionsController(
|
||||
i.CreatorPermission,
|
||||
i.GeneralPermission,
|
||||
IsAGInvention = i.IsAgInvention,
|
||||
Price = 0,
|
||||
// The 2023-03-21 DTO dropped IsPublished and reads "Accessibility"
|
||||
// instead (IOEPPCKGBFL.txt:309 / :1602 — the member switch has no
|
||||
// IsPublished arm at all). Constants come from the nested enum
|
||||
// AEFFFPIJDHG.GAKJKOGJEEH in the 2023.06 dump
|
||||
// (dump.cs:1282644-1282646): Private=0, Public=1, Unlisted=2. With the
|
||||
// key absent the reader left the field at its default, so EVERY
|
||||
// published store invention presented as Private in the 2023 UI.
|
||||
// InventionEntity has no Accessibility column, so only the
|
||||
// Private/Public bit round-trips; Unlisted needs a real column.
|
||||
// IsPublished stays on the wire because the 2020.12 watch's
|
||||
// OBBBPCBIMME reader still requires it.
|
||||
Accessibility = i.IsPublished ? 1 : 0,
|
||||
// IOEPPCKGBFL.txt:565 — DorkNet runs no certification programme.
|
||||
IsCertifiedInvention = false,
|
||||
i.Price,
|
||||
HideFromPlayer = false,
|
||||
};
|
||||
|
||||
private static object ToWireV4(InventionEntity i) => ToWire(i);
|
||||
|
||||
private static object ToVersionWire(InventionVersionEntity v) => new
|
||||
private static object ToVersionWire(InventionVersionEntity v)
|
||||
=> ToVersionWire(v, 0, 0);
|
||||
|
||||
/// <summary>The 2023 InventionVersion DTO reads NINE keys
|
||||
/// (OLPHKLCPFEF.txt:084-295): the six we already emitted plus
|
||||
/// <c>ChipsCost</c>, <c>CloudVariablesCost</c> and <c>BlobHash</c>.
|
||||
/// <see cref="InventionVersionEntity"/> has no column for any of the three,
|
||||
/// so stored rows report 0/empty and only the save/addversion round-trip can
|
||||
/// echo back the costs the client just sent. An empty BlobHash is safe:
|
||||
/// PLIKEBBPJGI's hash getter (<c>EHENFDMIAIM</c>, OLPHKLCPFEF's owner type
|
||||
/// PLIKEBBPJGI.txt:167) has no call site anywhere in the IsilDump — the
|
||||
/// client only ever writes it — so the key's presence is what matters.</summary>
|
||||
private static object ToVersionWire(
|
||||
InventionVersionEntity v, int chipsCost, int cloudVariablesCost) => new
|
||||
{
|
||||
v.InventionId,
|
||||
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
|
||||
@@ -699,6 +1449,34 @@ public class InventionsController(
|
||||
v.VersionNumber,
|
||||
v.InstantiationCost,
|
||||
v.LightsCost,
|
||||
ChipsCost = chipsCost,
|
||||
CloudVariablesCost = cloudVariablesCost,
|
||||
v.BlobName,
|
||||
BlobHash = string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>Every invention MUTATION route answers with the
|
||||
/// Status/Invention/InventionVersion envelope, never a bare invention: the
|
||||
/// 2023 client deserialises <c>BDNCJIPHHOK</c> (key literals at
|
||||
/// GMBGBPNMGBA.txt:044-095) and the 2020.12 watch deserialises
|
||||
/// <c>AHEPPAEOLOD</c>, whose reader pulls the same three literals
|
||||
/// (2020.12 IsilDump AHEPPAEOLOD.txt:110/115/120). Returning the invention
|
||||
/// flat left <c>Invention</c> null on 2023 and threw KeyNotFoundException on
|
||||
/// December. <c>Status = 0</c> is success —
|
||||
/// EnterInventionNameDialog.txt:622-624 loads the boxed Status field and
|
||||
/// does <c>test eax,eax / je <success></c>.</summary>
|
||||
private async Task<object> EnvelopeAsync(InventionEntity inv)
|
||||
{
|
||||
var current = await db.InventionVersions
|
||||
.Where(x => x.InventionId == inv.Id)
|
||||
.OrderByDescending(x => x.VersionNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
object? versionWire = current is null ? null : ToVersionWire(current);
|
||||
return new
|
||||
{
|
||||
Status = 0,
|
||||
Invention = ToWire(inv),
|
||||
InventionVersion = versionWire,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -66,13 +67,97 @@ public class InventoryController(
|
||||
public string PrefabName { get; set; } = string.Empty;
|
||||
public string ModificationGuid { get; set; } = string.Empty;
|
||||
public bool Favorited { get; set; }
|
||||
// The 2023 client sends Equipped/IsEquipped when a weapon skin is
|
||||
// equipped (distinct from Favorited). We only have one IsActive flag,
|
||||
// and for skins "active" == "equipped", so honor whichever the client
|
||||
// sent. Without this the equip was dropped (only Favorited was read)
|
||||
// and the skin never stuck.
|
||||
public bool? Equipped { get; set; }
|
||||
public bool? IsEquipped { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
|
||||
/// "favorited" flag on a piece of equipment (the watch lets the
|
||||
/// player pin commonly-used tools).</summary>
|
||||
/// <remarks>The 2023-03-21 client sends a JSON <b>array</b> of equipment
|
||||
/// updates here, not a single object (it serialises a
|
||||
/// <c>List<BCINJINBBHG></c>). Binding <c>[FromBody]</c> onto one
|
||||
/// object failed model binding and returned an automatic 400, so equip and
|
||||
/// favourite changes never persisted for that build. The body is now read
|
||||
/// leniently and both shapes are applied through the same per-item path.
|
||||
/// </remarks>
|
||||
[HttpPost("api/equipment/v1/update")]
|
||||
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> UpdateEquipment()
|
||||
{
|
||||
var requests = await ReadEquipmentUpdatesAsync();
|
||||
if (requests.Count == 0) return BadRequest("missing prefab/mod identifier");
|
||||
|
||||
IActionResult last = Ok();
|
||||
foreach (var req in requests)
|
||||
{
|
||||
last = await ApplyEquipmentUpdateAsync(req);
|
||||
// Surface the first rejection rather than pretending the whole
|
||||
// batch succeeded.
|
||||
if (last is not OkObjectResult and not OkResult) return last;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/// <summary>Accepts a single object, an array of objects, or a form post.</summary>
|
||||
private async Task<List<EquipmentUpdateRequest>> ReadEquipmentUpdatesAsync()
|
||||
{
|
||||
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var result = new List<EquipmentUpdateRequest>();
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
string? S(params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
if (form.TryGetValue(k, out var v) && !string.IsNullOrEmpty(v)) return v.ToString();
|
||||
return null;
|
||||
}
|
||||
bool? B(params string[] keys)
|
||||
=> bool.TryParse(S(keys), out var b) ? b : null;
|
||||
result.Add(new EquipmentUpdateRequest
|
||||
{
|
||||
PrefabName = S("PrefabName", "prefabName"),
|
||||
ModificationGuid = S("ModificationGuid", "modificationGuid"),
|
||||
Favorited = B("Favorited", "favorited") ?? false,
|
||||
Equipped = B("Equipped", "equipped"),
|
||||
IsEquipped = B("IsEquipped", "isEquipped"),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
Request.Body.Position = 0;
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in root.EnumerateArray())
|
||||
{
|
||||
var one = el.Deserialize<EquipmentUpdateRequest>(opts);
|
||||
if (one is not null) result.Add(one);
|
||||
}
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var one = root.Deserialize<EquipmentUpdateRequest>(opts);
|
||||
if (one is not null) result.Add(one);
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* unparseable body → treated as empty */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ApplyEquipmentUpdateAsync(EquipmentUpdateRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
// Catalog-backed skins use a compound slug so we can round-trip
|
||||
@@ -103,7 +188,27 @@ public class InventoryController(
|
||||
// to themselves).
|
||||
return Forbid();
|
||||
}
|
||||
row.IsActive = req.Favorited;
|
||||
// "equipped" is the meaningful state for weapon skins; prefer it,
|
||||
// fall back to Favorited for plain tool-pin updates.
|
||||
var active = req.Equipped ?? req.IsEquipped ?? req.Favorited;
|
||||
|
||||
// One skin equipped per weapon: when equipping a catalog-backed skin,
|
||||
// clear the equipped flag on the player's OTHER skins for the SAME
|
||||
// prefab (slugs share the "{prefix}{prefab}:" head) so the weapon
|
||||
// doesn't end up with two "equipped" skins.
|
||||
if (active && !string.IsNullOrWhiteSpace(req.ModificationGuid)
|
||||
&& !string.IsNullOrWhiteSpace(req.PrefabName))
|
||||
{
|
||||
var prefabPrefix = StoreService.EquipmentSkinSlug(req.PrefabName, string.Empty);
|
||||
var siblings = await db.PlayerInventory
|
||||
.Where(inv => inv.PlayerId == pid && inv.IsActive
|
||||
&& inv.ItemSlug != slug
|
||||
&& inv.ItemSlug.StartsWith(prefabPrefix))
|
||||
.ToListAsync();
|
||||
foreach (var sib in siblings) sib.IsActive = false;
|
||||
}
|
||||
|
||||
row.IsActive = active;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Updated = true });
|
||||
}
|
||||
@@ -182,6 +287,119 @@ public class InventoryController(
|
||||
return Ok(new { row.Id, Count = row.Quantity });
|
||||
}
|
||||
|
||||
/// <remarks>The 2023-03-21 client serialises a <c>TransferConsumableRequest</c>
|
||||
/// with <c>JsonUtility.ToJson</c> and posts it as a raw JSON body. With
|
||||
/// <c>[ApiController]</c> plus <c>[FromForm]</c> parameters, an
|
||||
/// <c>application/json</c> request bound nothing and the transfer was
|
||||
/// rejected as invalid, so consumable gifting never worked on that build.
|
||||
/// The JSON body is now read alongside the original form/query paths.</remarks>
|
||||
[HttpPost("api/consumables/v1/transfer")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> TransferConsumable(
|
||||
[FromForm] long? id,
|
||||
[FromForm] long? recipientPlayerId,
|
||||
[FromForm] int quantity = 1)
|
||||
{
|
||||
var pid = Me;
|
||||
var body = await ReadTransferBodyAsync();
|
||||
var inventoryId = id
|
||||
?? body.Id
|
||||
?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
|
||||
var recipient = recipientPlayerId
|
||||
?? body.RecipientPlayerId
|
||||
?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
|
||||
var amount = Math.Max(1, body.Quantity ?? quantity);
|
||||
if (inventoryId <= 0 || recipient <= 0 || recipient == pid)
|
||||
return BadRequest("invalid_transfer");
|
||||
|
||||
var source = await db.PlayerInventory.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == inventoryId);
|
||||
if (source is null || source.Quantity < amount) return NotFound();
|
||||
var target = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == recipient && p.ItemSlug == source.ItemSlug);
|
||||
if (target is null)
|
||||
{
|
||||
target = new PlayerInventoryEntity
|
||||
{
|
||||
PlayerId = recipient,
|
||||
ItemSlug = source.ItemSlug,
|
||||
Quantity = 0,
|
||||
};
|
||||
db.PlayerInventory.Add(target);
|
||||
}
|
||||
|
||||
source.Quantity -= amount;
|
||||
target.Quantity += amount;
|
||||
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.NotifyAsync(recipient,
|
||||
PushNotificationId.ConsumableMappingAdded,
|
||||
new { target.Id, target.ItemSlug, target.Quantity });
|
||||
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
|
||||
}
|
||||
|
||||
private readonly record struct TransferBody(long? Id, long? RecipientPlayerId, int? Quantity);
|
||||
|
||||
/// <summary>Reads the 2023 client's raw JSON transfer body. JsonUtility
|
||||
/// emits field names verbatim, and the exact casing of that DTO is not
|
||||
/// recoverable from the ISIL, so both conventions are accepted.</summary>
|
||||
private async Task<TransferBody> ReadTransferBodyAsync()
|
||||
{
|
||||
if (Request.HasFormContentType) return default;
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
Request.Body.Position = 0;
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return default;
|
||||
|
||||
long? L(params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
if (doc.RootElement.TryGetProperty(k, out var e) &&
|
||||
e.ValueKind == JsonValueKind.Number && e.TryGetInt64(out var v)) return v;
|
||||
return null;
|
||||
}
|
||||
int? I(params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
if (doc.RootElement.TryGetProperty(k, out var e) &&
|
||||
e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v)) return v;
|
||||
return null;
|
||||
}
|
||||
return new TransferBody(
|
||||
L("Id", "id", "InventoryId", "inventoryId"),
|
||||
L("RecipientPlayerId", "recipientPlayerId"),
|
||||
I("Quantity", "quantity", "Count", "count"));
|
||||
}
|
||||
catch (JsonException) { return default; }
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/consumables/v1/getTransferable/{playerId}</c> — the
|
||||
/// consumables the caller may gift to <c>playerId</c>. No handler existed,
|
||||
/// so the transfer UI 404'd and could never list anything to send.
|
||||
/// Returns the same grouped shape as <c>v2/getUnlocked</c>.</summary>
|
||||
[HttpGet("api/consumables/v1/getTransferable/{playerId:long}")]
|
||||
public async Task<IActionResult> GetTransferableConsumables(long playerId)
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await db.PlayerInventory
|
||||
.Where(p => p.PlayerId == pid && p.Quantity > 0)
|
||||
.ToListAsync();
|
||||
|
||||
// Can't gift to yourself; everything else the caller holds is fair game.
|
||||
var transferable = playerId == pid ? [] : rows;
|
||||
return Ok(transferable
|
||||
.GroupBy(r => r.ItemSlug)
|
||||
.Select(g => new
|
||||
{
|
||||
Consumable = g.Key,
|
||||
Count = g.Sum(r => r.Quantity),
|
||||
Id = g.Min(r => r.Id),
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public sealed class ActivateConsumableRequest
|
||||
{
|
||||
public long Id { get; set; }
|
||||
@@ -223,7 +441,11 @@ public class InventoryController(
|
||||
FriendlyName = item.DisplayName,
|
||||
Tooltip = item.Description,
|
||||
Rarity = rarity,
|
||||
// IsActive is the equipped/active flag; surface it under every key
|
||||
// the 2023 client reads so the equipped skin shows on reload.
|
||||
Favorited = inv.IsActive,
|
||||
Equipped = inv.IsActive,
|
||||
IsEquipped = inv.IsActive,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ItemWishlists;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/itemWishlists — consumable/gift-drop wishlist
|
||||
/// (RecNet.Runtime BPAIBFBKKCN). Wire contract verified against the
|
||||
/// 2023.03.21 ISIL decompile:
|
||||
///
|
||||
/// GET api/itemWishlists/v1/wishlist/{accountId|me}
|
||||
/// - BPAIBFBKKCN.txt:700-763 (ELPBIBPOBJF) — URL is
|
||||
/// String.Concat("api/itemWishlists/v1/wishlist/", idOrMe) where
|
||||
/// idOrMe is "me" for the local account, else the numeric account
|
||||
/// id as a PATH SEGMENT (not a query param). Missing this route
|
||||
/// 404s with an empty body → CHGIJBGGJAG "Response was empty" →
|
||||
/// "Failed to get wishlist for player" (Player.log:2832-2872).
|
||||
/// - Return type FGLDKEJLAKB<List<BFJNGMGONED>> with no converter
|
||||
/// Func → the body is a BARE ARRAY of BFJNGMGONED.
|
||||
/// - BFJNGMGONED = { Guid WishlistItemId, Int32 AccountId,
|
||||
/// Int32 PurchasableItemId, DateTime CreatedAt }
|
||||
/// (property types BFJNGMGONED.txt:3-97; JSON keys registered by
|
||||
/// reader MCDADMNCDEI.txt:343-434, Pascal/camel/lower probes).
|
||||
///
|
||||
/// PUT api/itemWishlists/v1/wishlist/me/{purchasableItemId}
|
||||
/// - BPAIBFBKKCN.txt:161-215 (OOPPFFPOAFH) — format string
|
||||
/// "{0}/v1/wishlist/me/{1}", verb field = 3 = PUT
|
||||
/// (verb table HNLCIDLIIBO.txt:878-903). Response is parsed as the
|
||||
/// AECMPGPHAII<BFJNGMGONED> envelope {"Value","Success","Error"}
|
||||
/// (envelope reader keys LEEAJGDIOHI.txt:243-286).
|
||||
///
|
||||
/// DELETE api/itemWishlists/v1/wishlist/me/{purchasableItemId}
|
||||
/// - BPAIBFBKKCN.txt:375-419 (IBLKDCAIODF) — same URL, verb 4 =
|
||||
/// DELETE, fire-and-forget (LDGADANDBIO) — body ignored.
|
||||
///
|
||||
/// Storage: rows live in ItemWishlists with the purchasable item id
|
||||
/// serialized into the existing ItemKey column (no schema change);
|
||||
/// WishlistItemId is derived deterministically from the row id.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("api/itemWishlists/v1/wishlist/me")]
|
||||
public async Task<IActionResult> Mine() => await ForPlayer(Me);
|
||||
|
||||
[HttpGet("api/itemWishlists/v1/wishlist/{accountId:long}")]
|
||||
public async Task<IActionResult> ForPlayer(long accountId)
|
||||
{
|
||||
var rows = await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == accountId)
|
||||
.OrderByDescending(w => w.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire).Where(w => w is not null).ToArray());
|
||||
}
|
||||
|
||||
[HttpPut("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
|
||||
[HttpPost("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
|
||||
public async Task<IActionResult> Add(int purchasableItemId)
|
||||
{
|
||||
var key = purchasableItemId.ToString();
|
||||
var row = await db.ItemWishlists.FirstOrDefaultAsync(w =>
|
||||
w.PlayerId == Me && w.ItemKey == key);
|
||||
if (row is null)
|
||||
{
|
||||
row = new ItemWishlistEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
ItemKey = key,
|
||||
ItemType = 0,
|
||||
};
|
||||
db.ItemWishlists.Add(row);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// AECMPGPHAII<BFJNGMGONED> envelope: {"Value","Success","Error"}
|
||||
// (LEEAJGDIOHI.txt:243-286).
|
||||
return Ok(new
|
||||
{
|
||||
Value = ToWire(row),
|
||||
Success = true,
|
||||
Error = (string?)null,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
|
||||
public async Task<IActionResult> Remove(int purchasableItemId)
|
||||
{
|
||||
var key = purchasableItemId.ToString();
|
||||
await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == Me && w.ItemKey == key)
|
||||
.ExecuteDeleteAsync();
|
||||
// Fire-and-forget on the client (LDGADANDBIO) — body ignored.
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
/// <summary>BFJNGMGONED wire item. Rows whose ItemKey isn't a
|
||||
/// purchasable-item integer (legacy free-form keys) are skipped.</summary>
|
||||
private static object? ToWire(ItemWishlistEntity row)
|
||||
{
|
||||
if (!int.TryParse(row.ItemKey, out var purchasableItemId)) return null;
|
||||
return new
|
||||
{
|
||||
WishlistItemId = StableGuid(row.Id, row.PlayerId),
|
||||
AccountId = (int)row.PlayerId,
|
||||
PurchasableItemId = purchasableItemId,
|
||||
row.CreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Deterministic per-row Guid — the client only uses
|
||||
/// WishlistItemId as an opaque identity, but it must stay stable
|
||||
/// across requests.</summary>
|
||||
private static Guid StableGuid(long rowId, long playerId)
|
||||
{
|
||||
Span<byte> input = stackalloc byte[16];
|
||||
BitConverter.TryWriteBytes(input, rowId);
|
||||
BitConverter.TryWriteBytes(input[8..], playerId);
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(input, hash);
|
||||
return new Guid(hash[..16]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
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.Keepsakes;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/keepsakes")]
|
||||
[Authorize]
|
||||
public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
/// <summary>Marker category for collection rows; never surfaced as a keepsake instance.</summary>
|
||||
private const string CollectionCategory = "collection";
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <c>KeepsakeGlobalConfigDTO.SocialXpBoostEnabled</c> below. The collect handler has
|
||||
/// to agree with what <c>globalconfig</c> advertises, so the flag lives in one place.
|
||||
/// </summary>
|
||||
private const bool SocialXpBoostEnabled = false;
|
||||
|
||||
private const int KeepsakeRoomLimit = 64;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Mine()
|
||||
{
|
||||
await EnsureLoginKeepsakeAsync(Me);
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == Me && k.Category != CollectionCategory)
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
// KeepsakeCategoryConfigDTO = {KeepsakeCategoryId(enum), VisualId, LimitPerRoom, XpValue,
|
||||
// IconOutlineImageName, IconFilledImageName} - dump.cs:1234842-1234878; the accepted wire key
|
||||
// names are enumerated by its Utf8Json formatter (IsilDump/RecNet.Runtime/HIKOJENMDKE.txt:66-160
|
||||
// - "KeepsakeCategoryId"/"VisualId"/"LimitPerRoom"/"XpValue" plus camel/lower aliases).
|
||||
// KeepsakeCategoryId is the PIHCLHIKEPH enum (dump.cs:1199524: Explore=0, GreenPowerCore=1,
|
||||
// Present=2, PurplePowerCore=3, UnnamedKeepsakeNumber1..5 = 4..8), and the client folds this
|
||||
// list into a Dictionary<PIHCLHIKEPH, KeepsakeCategoryConfigDTO> (NCCLEJPIABA.DHMFBMMAGCL,
|
||||
// NCCLEJPIABA.txt:4204) that every placed instance is looked up in. The previous three-entry
|
||||
// list keyed by our internal account/event/room buckets covered ids 0-2 only, so six of the
|
||||
// nine placeable categories had no config - and, since collect prices the reward from XpValue,
|
||||
// no reward either.
|
||||
private static readonly (int Id, string VisualId)[] CategoryConfigs =
|
||||
[
|
||||
(0, "Explore"),
|
||||
(1, "GreenPowerCore"),
|
||||
(2, "Present"),
|
||||
(3, "PurplePowerCore"),
|
||||
(4, "UnnamedKeepsakeNumber1"),
|
||||
(5, "UnnamedKeepsakeNumber2"),
|
||||
(6, "UnnamedKeepsakeNumber3"),
|
||||
(7, "UnnamedKeepsakeNumber4"),
|
||||
(8, "UnnamedKeepsakeNumber5"),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// XP a single keepsake collection is worth. This is the one number both
|
||||
/// <c>api/keepsakes/categories</c> (as <c>XpValue</c>) and
|
||||
/// <c>api/keepsakes/{id}/collect</c> (as <c>TotalXp</c>) are derived from, so the toast the
|
||||
/// client renders after a collect matches the config it already downloaded.
|
||||
/// </summary>
|
||||
private const int KeepsakeXpValue = 25;
|
||||
|
||||
[HttpGet("categories")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Categories()
|
||||
{
|
||||
var results = CategoryConfigs
|
||||
.Select(c => new
|
||||
{
|
||||
KeepsakeCategoryId = c.Id,
|
||||
c.VisualId,
|
||||
LimitPerRoom = KeepsakeRoomLimit,
|
||||
XpValue = KeepsakeXpValue,
|
||||
IconOutlineImageName = string.Empty,
|
||||
IconFilledImageName = string.Empty,
|
||||
})
|
||||
.ToArray();
|
||||
return Ok(new
|
||||
{
|
||||
Results = results,
|
||||
TotalResults = results.Length,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("events")]
|
||||
public async Task<IActionResult> Events()
|
||||
{
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == Me && k.Category == "event")
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("events/{eventId:long}")]
|
||||
public async Task<IActionResult> EventInstances(long eventId)
|
||||
{
|
||||
var eventKey = eventId.ToString(CultureInfo.InvariantCulture);
|
||||
// NOT filtered by PlayerId: Instances is the whole event's placement set (the hunt map),
|
||||
// CollectionRecords is only what the caller has picked up.
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.Category == "event"
|
||||
&& (k.EventKey == eventKey
|
||||
|| k.EventKey == $"event:{eventKey}"
|
||||
|| k.EventKey == $"event/{eventKey}"
|
||||
|| EF.Functions.Like(k.EventKey, $"event:{eventKey}:%")
|
||||
|| EF.Functions.Like(k.EventKey, $"event/{eventKey}/%")))
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
|
||||
// RecNet.KeepsakeProgressionEventInstancesDTO has exactly two members, both
|
||||
// List<KeepsakeRoomInstanceIdsDTO> (= {Int64, List<Guid>}) - dump.cs:1234798-1234810,
|
||||
// ctor(List<KeepsakeRoomInstanceIdsDTO>, List<KeepsakeRoomInstanceIdsDTO>) in
|
||||
// IsilDump/RecNet.Runtime/RecNet/KeepsakeProgressionEventInstancesDTO.txt:14.
|
||||
// Emitting the flat keepsake rows here left RoomId=0 / KeepsakeInstanceIds=null per entry.
|
||||
// Key names follow the same RecNet naming the room endpoint below already uses; the literal
|
||||
// JSON names live in Newtonsoft attribute metadata the dumps do not render, so they are
|
||||
// unverified - Json.NET matching is case-insensitive and ignores unknown members.
|
||||
//
|
||||
// CollectionRecords is what the CALLER has picked up, which is a different set from what the
|
||||
// caller PLACED: it is fed by the collect handler below (Category=CollectionCategory rows).
|
||||
var eventInstanceIds = rows
|
||||
.Select(r => TryParseInstanceKey(r.EventKey, out _, out _, out _, out var id) ? id : StableKeepsakeInstanceId(r.Id))
|
||||
.ToHashSet();
|
||||
var collected = (await LoadMyCollectionsAsync())
|
||||
.Where(c => eventInstanceIds.Contains(c.InstanceId))
|
||||
.GroupBy(c => c.RoomId)
|
||||
.Select(group => (object)new
|
||||
{
|
||||
RoomId = group.Key,
|
||||
KeepsakeInstanceIds = group.Select(c => c.InstanceId).Distinct().Select(id => id.ToString("D")).ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Instances = GroupInstanceIdsByRoom(rows),
|
||||
CollectionRecords = collected,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("rooms/{roomId:long}")]
|
||||
public async Task<IActionResult> RoomKeepsakes(long roomId)
|
||||
{
|
||||
var roomExists = await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId);
|
||||
if (!roomExists) return NotFound();
|
||||
|
||||
var roomIdText = roomId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.Category == "room"
|
||||
&& (k.EventKey == $"room:{roomIdText}"
|
||||
|| k.EventKey == $"room/{roomIdText}"
|
||||
|| k.EventKey == roomIdText
|
||||
|| EF.Functions.Like(k.EventKey, $"room:{roomIdText}:%")
|
||||
|| EF.Functions.Like(k.EventKey, $"room/{roomIdText}/%")))
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList();
|
||||
|
||||
// KeepsakeCollectionRecordDTO = {Int32 AccountId, Guid KeepsakeInstanceId, DateTime
|
||||
// CollectedAt} - dump.cs:1234642-1234666, wire keys confirmed by its Utf8Json formatter
|
||||
// (IsilDump/RecNet.Runtime/CNNAFLNKDCL.txt:48,75,99). Scoped to the caller: the DTO carries
|
||||
// AccountId so the client can filter, but returning only the caller's rows is correct under
|
||||
// either reading and keeps other players' pickups out of the payload.
|
||||
var collectPrefix = $"collect:{roomIdText}:";
|
||||
var me = Me;
|
||||
var collectionRecords = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == me
|
||||
&& k.Category == CollectionCategory
|
||||
&& EF.Functions.Like(k.EventKey, collectPrefix + "%"))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Instances = instances,
|
||||
CollectionRecords = collectionRecords
|
||||
.Where(k => TryParseCollectionKey(k.EventKey, out _, out _))
|
||||
.Select(k =>
|
||||
{
|
||||
TryParseCollectionKey(k.EventKey, out _, out var instanceId);
|
||||
return (object)new
|
||||
{
|
||||
AccountId = k.PlayerId is > 0 and <= int.MaxValue ? (int)k.PlayerId : 0,
|
||||
KeepsakeInstanceId = instanceId.ToString("D"),
|
||||
CollectedAt = k.EarnedAt,
|
||||
};
|
||||
})
|
||||
.ToList(),
|
||||
KeepsakeProgressionEventIds = Array.Empty<long>(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a placed keepsake instance. The 2023 client issues
|
||||
/// <c>DELETE api/keepsakes/{guid}</c> - <c>NCCLEJPIABA.JBKENCNIEPA(System.Guid)</c> builds
|
||||
/// <c>String.Format("{0}/{1}", "api/keepsakes", guid)</c> and passes verb 4 (DELETE) to the
|
||||
/// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - <c>Move rdx, 4</c>). Its return type is
|
||||
/// the non-generic <c>LDGADANDBIO</c> promise, so no response body is deserialised; a bare 200
|
||||
/// is enough. Client-side the tool refuses unless the caller is at least a room co-owner
|
||||
/// ("Must be at least a room co-owner to modify keepsakes.",
|
||||
/// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side.
|
||||
/// </summary>
|
||||
[HttpDelete("{keepsakeInstanceId:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid keepsakeInstanceId)
|
||||
{
|
||||
var instance = await FindInstanceAsync(keepsakeInstanceId);
|
||||
if (instance is null) return NotFound();
|
||||
|
||||
var roomId = TryParseInstanceKey(instance.EventKey, out var parsedRoomId, out _, out _, out _)
|
||||
? parsedRoomId
|
||||
: 0;
|
||||
if (!await CanModifyInstanceAsync(instance, roomId, Me)) return Forbid();
|
||||
|
||||
// Drop the collection rows for this instance too, or they linger as records pointing at a
|
||||
// keepsake nobody can see and keep suppressing a future re-placement's collect.
|
||||
var suffix = keepsakeInstanceId.ToString("N");
|
||||
var orphanedCollections = await db.Keepsakes
|
||||
.Where(k => k.Category == CollectionCategory && EF.Functions.Like(k.EventKey, $"%:{suffix}"))
|
||||
.ToListAsync();
|
||||
|
||||
db.Keepsakes.Remove(instance);
|
||||
db.Keepsakes.RemoveRange(orphanedCollections
|
||||
.Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that the caller picked up a placed keepsake and reports the XP it was worth. The
|
||||
/// 2023 client issues <c>POST api/keepsakes/{guid}/collect</c> -
|
||||
/// <c>NCCLEJPIABA.HFGKFAHFELM(System.Guid)</c> formats <c>"{0}/{1}/collect"</c> over
|
||||
/// "api/keepsakes" and passes verb 2 (POST) (NCCLEJPIABA.txt:1738, 1963-1974 -
|
||||
/// <c>Move rdx, 2</c>), with no request body. The response type is
|
||||
/// <c>FGLDKEJLAKB<DHNBKMHDANK></c>; DHNBKMHDANK is a two-Int32 object whose Utf8Json
|
||||
/// formatter writes "TotalXp" and "SocialBoostXp" (RecNet.Runtime/PKCMBJFBHBO.txt:42,69,100,121
|
||||
/// - reader also accepts totalXp/socialBoostXp). The consumer computes
|
||||
/// <c>TotalXp - SocialBoostXp</c> for the base figure and shows the boost separately
|
||||
/// (PDFJLLECNBE_NestedType_LKIPMJFEAFK.txt:82-99), so TotalXp is the whole award, boost
|
||||
/// included - not a running account total.
|
||||
/// </summary>
|
||||
[HttpPost("{keepsakeInstanceId:guid}/collect")]
|
||||
public async Task<IActionResult> Collect(Guid keepsakeInstanceId)
|
||||
{
|
||||
var instance = await FindInstanceAsync(keepsakeInstanceId);
|
||||
if (instance is null) return NotFound();
|
||||
|
||||
var parsed = TryParseInstanceKey(instance.EventKey, out var roomId, out _, out var categoryConfigId, out _);
|
||||
if (!parsed)
|
||||
{
|
||||
roomId = 0;
|
||||
categoryConfigId = CategoryId(instance.Category);
|
||||
}
|
||||
|
||||
var me = Me;
|
||||
var collectKey = BuildCollectionKey(roomId, keepsakeInstanceId);
|
||||
var alreadyCollected = await db.Keepsakes
|
||||
.AnyAsync(k => k.PlayerId == me && k.Category == CollectionCategory && k.EventKey == collectKey);
|
||||
|
||||
// Re-collecting is a no-op award rather than an error: the client only ever renders the two
|
||||
// numbers, and paying out twice for one instance would be a fabricated reward.
|
||||
if (alreadyCollected) return Ok(new { TotalXp = 0, SocialBoostXp = 0 });
|
||||
|
||||
db.Keepsakes.Add(new KeepsakeEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Category = CollectionCategory,
|
||||
EventKey = collectKey,
|
||||
EarnedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
TotalXp = XpValueFor(categoryConfigId),
|
||||
// SocialBoostXp is the boost slice *of* TotalXp - the client renders
|
||||
// TotalXp - SocialBoostXp as the base figure - and globalconfig above advertises
|
||||
// SocialXpBoostEnabled=false, so there is no slice and the whole award is base XP.
|
||||
SocialBoostXp = 0,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("globalconfig")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult GlobalConfig() => Ok(new
|
||||
{
|
||||
KeepsakeFeatureEnabled = true,
|
||||
KeepsakeRoomLimit,
|
||||
SocialXpBoostEnabled,
|
||||
});
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var req = await ReadRequestAsync();
|
||||
|
||||
// 2023 client: FGLDKEJLAKB<System.Guid> OPPHCOJHEJI(HMECHOKOCBB category)
|
||||
// (NCCLEJPIABA.txt:1146) POSTs a RecNet.AddKeepsakeInstanceRequest
|
||||
// {Int64 RoomId, Int64? SubRoomId, Int32 KeepsakeCategory} - dump.cs:1234509-1234525,
|
||||
// field offsets 0x10/0x18/0x28 in AddKeepsakeInstanceRequest.txt - and deserialises the
|
||||
// response as a BARE Guid, not an object. The request is refused client-side for offline
|
||||
// rooms ("Cannot add keepsakes in offline rooms", NCCLEJPIABA.txt:1533), so RoomId is
|
||||
// always a real room here; its presence is what selects this path over the legacy DTO.
|
||||
if (req.RoomId is > 0)
|
||||
{
|
||||
var roomId = req.RoomId.Value;
|
||||
if (!await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId)) return NotFound();
|
||||
|
||||
var instanceId = Guid.NewGuid();
|
||||
db.Keepsakes.Add(new KeepsakeEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Category = "room",
|
||||
EventKey = BuildRoomInstanceKey(roomId, req.SubRoomId, req.KeepsakeCategory ?? 0, instanceId),
|
||||
EarnedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(instanceId);
|
||||
}
|
||||
|
||||
var key = Trim(req.EventKey, 128);
|
||||
if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}";
|
||||
var category = Trim(req.Category, 64);
|
||||
if (category.Length == 0) category = "event";
|
||||
|
||||
var existing = await db.Keepsakes.FirstOrDefaultAsync(k =>
|
||||
k.PlayerId == Me && k.EventKey == key);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new KeepsakeEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
EventKey = key,
|
||||
};
|
||||
db.Keepsakes.Add(existing);
|
||||
}
|
||||
|
||||
existing.Category = category;
|
||||
existing.Title = Trim(req.Title, 128);
|
||||
existing.Description = Trim(req.Description, 1024);
|
||||
existing.ImageName = Trim(req.ImageName, 256);
|
||||
existing.EarnedAt = req.EarnedAt ?? DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(existing));
|
||||
}
|
||||
|
||||
private async Task EnsureLoginKeepsakeAsync(long playerId)
|
||||
{
|
||||
const string key = "first-login";
|
||||
if (await db.Keepsakes.AnyAsync(k => k.PlayerId == playerId && k.EventKey == key))
|
||||
return;
|
||||
db.Keepsakes.Add(new KeepsakeEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Category = "account",
|
||||
EventKey = key,
|
||||
Title = "First Login",
|
||||
Description = "Joined this server.",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<KeepsakeRequest> ReadRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new KeepsakeRequest
|
||||
{
|
||||
RoomId = long.TryParse(form["RoomId"].FirstOrDefault() ?? form["roomId"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formRoom) ? formRoom : null,
|
||||
SubRoomId = long.TryParse(form["SubRoomId"].FirstOrDefault() ?? form["subRoomId"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formSubRoom) ? formSubRoom : null,
|
||||
KeepsakeCategory = int.TryParse(form["KeepsakeCategory"].FirstOrDefault() ?? form["keepsakeCategory"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formCategory) ? formCategory : null,
|
||||
Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(),
|
||||
EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(),
|
||||
Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(),
|
||||
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
|
||||
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
|
||||
EarnedAt = DateTime.TryParse(form["earnedAt"].FirstOrDefault() ?? form["EarnedAt"].FirstOrDefault(), out var earned) ? earned : null,
|
||||
};
|
||||
}
|
||||
|
||||
// The 2023 client sends a RAW JSON body, not a form: NCCLEJPIABA.txt:1458-1465 serialises the
|
||||
// whole AddKeepsakeInstanceRequest in one call and hands the string to BNDIAONDFFF's body
|
||||
// setter. Read it as a document rather than binding a DTO so the numeric KeepsakeCategory and
|
||||
// the legacy string Category can share one wire slot, and so key-name aliases are cheap.
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
return new KeepsakeRequest
|
||||
{
|
||||
RoomId = ReadInt64(root, "RoomId"),
|
||||
SubRoomId = ReadInt64(root, "SubRoomId"),
|
||||
// "Category" is listed as an alias only for the numeric form; when it carries the
|
||||
// legacy string ("event"/"room") ReadInt64 declines it and ReadString picks it up.
|
||||
KeepsakeCategory = (int?)ReadInt64(root, "KeepsakeCategory", "KeepsakeCategoryConfigId", "Category"),
|
||||
Category = ReadString(root, "Category"),
|
||||
EventKey = ReadString(root, "EventKey"),
|
||||
Title = ReadString(root, "Title"),
|
||||
Description = ReadString(root, "Description"),
|
||||
ImageName = ReadString(root, "ImageName"),
|
||||
EarnedAt = DateTime.TryParse(ReadString(root, "EarnedAt"), out var earnedAt) ? earnedAt : null,
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new KeepsakeRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryFindMember(JsonElement root, string[] names, out JsonElement value)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static long? ReadInt64(JsonElement root, params string[] names)
|
||||
{
|
||||
if (!TryFindMember(root, names, out var el)) return null;
|
||||
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var number)) return number;
|
||||
if (el.ValueKind == JsonValueKind.String
|
||||
&& long.TryParse(el.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement root, params string[] names)
|
||||
=> TryFindMember(root, names, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null;
|
||||
|
||||
private object ToWire(KeepsakeEntity row) => new
|
||||
{
|
||||
row.Id,
|
||||
row.PlayerId,
|
||||
row.Category,
|
||||
row.EventKey,
|
||||
row.Title,
|
||||
row.Description,
|
||||
row.ImageName,
|
||||
ImageUrl = string.IsNullOrWhiteSpace(row.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{row.ImageName}",
|
||||
row.EarnedAt,
|
||||
};
|
||||
|
||||
private static object ToRoomInstanceWire(KeepsakeEntity row, long roomId)
|
||||
{
|
||||
var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue
|
||||
? (int)row.PlayerId
|
||||
: 0;
|
||||
var parsed = TryParseInstanceKey(row.EventKey, out var keyRoomId, out var subRoomId, out var categoryConfigId, out var instanceId);
|
||||
return new
|
||||
{
|
||||
KeepsakeInstanceId = (parsed ? instanceId : StableKeepsakeInstanceId(row.Id)).ToString("D"),
|
||||
// KeepsakeCategory is the visual config id (enum PIHCLHIKEPH: Explore=0, GreenPowerCore=1,
|
||||
// Present=2, PurplePowerCore=3, ... - dump.cs:1199524), NOT our account/event/room bucket,
|
||||
// so replay what the client sent; CategoryId() is only the fallback for legacy rows.
|
||||
KeepsakeCategoryConfigId = parsed ? categoryConfigId : CategoryId(row.Category),
|
||||
PlacedByAccountId = placedByAccountId,
|
||||
RoomId = parsed ? keyRoomId : roomId,
|
||||
SubRoomId = subRoomId,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<object> GroupInstanceIdsByRoom(IEnumerable<KeepsakeEntity> rows) => rows
|
||||
.Select(row => TryParseInstanceKey(row.EventKey, out var roomId, out _, out _, out var instanceId)
|
||||
? (RoomId: roomId, InstanceId: instanceId)
|
||||
: (RoomId: 0L, InstanceId: StableKeepsakeInstanceId(row.Id)))
|
||||
.GroupBy(x => x.RoomId)
|
||||
.Select(group => (object)new
|
||||
{
|
||||
RoomId = group.Key,
|
||||
KeepsakeInstanceIds = group.Select(x => x.InstanceId).Distinct().Select(id => id.ToString("D")).ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// KeepsakeEntity has no room/sub-room/instance-id columns, so a placed instance is encoded into
|
||||
// the existing 128-char EventKey. Layouts:
|
||||
// room:{roomId}:{subRoomId|-}:{categoryConfigId}:{instanceGuid:N} (client placements)
|
||||
// event:{eventId}:{roomId}:{subRoomId|-}:{categoryConfigId}:{instanceGuid:N}
|
||||
// Both stay inside the LIKE prefixes the room/event queries above already use. Legacy short keys
|
||||
// ("room:{roomId}", "event:{eventId}", "first-login") fail to parse and fall back to the
|
||||
// MD5-derived instance id, which is what those rows were already reported with.
|
||||
private static string BuildRoomInstanceKey(long roomId, long? subRoomId, int categoryConfigId, Guid instanceId)
|
||||
{
|
||||
var room = roomId.ToString(CultureInfo.InvariantCulture);
|
||||
var subRoom = subRoomId?.ToString(CultureInfo.InvariantCulture) ?? "-";
|
||||
var category = categoryConfigId.ToString(CultureInfo.InvariantCulture);
|
||||
return $"room:{room}:{subRoom}:{category}:{instanceId:N}";
|
||||
}
|
||||
|
||||
private static bool TryParseInstanceKey(string? eventKey, out long roomId, out long? subRoomId, out int categoryConfigId, out Guid instanceId)
|
||||
{
|
||||
roomId = 0;
|
||||
subRoomId = null;
|
||||
categoryConfigId = 0;
|
||||
instanceId = Guid.Empty;
|
||||
|
||||
var parts = (eventKey ?? string.Empty).Split(':');
|
||||
int i;
|
||||
if (parts.Length == 5 && string.Equals(parts[0], "room", StringComparison.OrdinalIgnoreCase)) i = 1;
|
||||
else if (parts.Length == 6 && string.Equals(parts[0], "event", StringComparison.OrdinalIgnoreCase)) i = 2;
|
||||
else return false;
|
||||
|
||||
if (!Guid.TryParseExact(parts[i + 3], "N", out instanceId)) return false;
|
||||
if (!long.TryParse(parts[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)) return false;
|
||||
if (long.TryParse(parts[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sub)) subRoomId = sub;
|
||||
if (!int.TryParse(parts[i + 2], NumberStyles.Integer, CultureInfo.InvariantCulture, out categoryConfigId)) categoryConfigId = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// A collection record is stored as its own keepsake row so no schema change is needed:
|
||||
// Category = "collection", EventKey = "collect:{roomId}:{instanceGuid:N}"
|
||||
// The room id is baked in so the room endpoint can filter by prefix and the event endpoint can
|
||||
// group by room without re-reading the instance rows. "collect:" shares no prefix with the
|
||||
// "room:"/"event:" instance keys, so collection rows never leak into an instance query, and
|
||||
// Mine() filters the category out of the legacy keepsake list.
|
||||
private static string BuildCollectionKey(long roomId, Guid instanceId)
|
||||
=> $"collect:{roomId.ToString(CultureInfo.InvariantCulture)}:{instanceId:N}";
|
||||
|
||||
private static bool TryParseCollectionKey(string? eventKey, out long roomId, out Guid instanceId)
|
||||
{
|
||||
roomId = 0;
|
||||
instanceId = Guid.Empty;
|
||||
var parts = (eventKey ?? string.Empty).Split(':');
|
||||
if (parts.Length != 3 || !string.Equals(parts[0], "collect", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)) return false;
|
||||
return Guid.TryParseExact(parts[2], "N", out instanceId);
|
||||
}
|
||||
|
||||
private async Task<List<(long RoomId, Guid InstanceId)>> LoadMyCollectionsAsync()
|
||||
{
|
||||
var me = Me;
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == me && k.Category == CollectionCategory)
|
||||
.Select(k => k.EventKey)
|
||||
.ToListAsync();
|
||||
var results = new List<(long RoomId, Guid InstanceId)>(rows.Count);
|
||||
foreach (var key in rows)
|
||||
{
|
||||
if (TryParseCollectionKey(key, out var roomId, out var instanceId))
|
||||
results.Add((roomId, instanceId));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the guid the client holds (from <c>api/keepsakes/rooms/{roomId}</c>, or the guid
|
||||
/// this server handed back from POST <c>api/keepsakes</c>) to the row that encodes it.
|
||||
/// </summary>
|
||||
private async Task<KeepsakeEntity?> FindInstanceAsync(Guid keepsakeInstanceId)
|
||||
{
|
||||
var suffix = keepsakeInstanceId.ToString("N");
|
||||
var candidates = await db.Keepsakes
|
||||
.Where(k => (k.Category == "room" || k.Category == "event")
|
||||
&& EF.Functions.Like(k.EventKey, $"%:{suffix}"))
|
||||
.ToListAsync();
|
||||
var match = candidates.FirstOrDefault(k =>
|
||||
TryParseInstanceKey(k.EventKey, out _, out _, out _, out var id) && id == keepsakeInstanceId);
|
||||
if (match is not null) return match;
|
||||
|
||||
// Rows written before the instance id was encoded into EventKey are reported to the client
|
||||
// with the MD5-derived StableKeepsakeInstanceId, which no LIKE can match. Resolve those by
|
||||
// id only - the projection keeps it to one column over the short-key rows.
|
||||
var legacyIds = await db.Keepsakes
|
||||
.Where(k => (k.Category == "room" || k.Category == "event")
|
||||
&& !EF.Functions.Like(k.EventKey, "%:%:%:%"))
|
||||
.Select(k => k.Id)
|
||||
.ToListAsync();
|
||||
var legacyId = legacyIds.FirstOrDefault(id => StableKeepsakeInstanceId(id) == keepsakeInstanceId);
|
||||
return legacyId == 0 ? null : await db.Keepsakes.FirstOrDefaultAsync(k => k.Id == legacyId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Placement and removal are gated client-side on room co-ownership
|
||||
/// ("Must be at least a room co-owner to modify keepsakes.", PDFJLLECNBE.txt:3363); the placer
|
||||
/// and server admins are allowed through as well.
|
||||
/// </summary>
|
||||
private async Task<bool> CanModifyInstanceAsync(KeepsakeEntity instance, long roomId, long playerId)
|
||||
{
|
||||
if (instance.PlayerId == playerId) return true;
|
||||
|
||||
if (roomId > 0)
|
||||
{
|
||||
var creatorId = await db.Rooms.AsNoTracking()
|
||||
.Where(r => r.Id == roomId)
|
||||
.Select(r => (long?)r.CreatorPlayerId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (creatorId == playerId) return true;
|
||||
|
||||
var isCoOwner = await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && 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 static int XpValueFor(int keepsakeCategoryConfigId)
|
||||
=> CategoryConfigs.Any(c => c.Id == keepsakeCategoryConfigId) ? KeepsakeXpValue : 0;
|
||||
|
||||
private static int CategoryId(string? category) =>
|
||||
string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 :
|
||||
string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 :
|
||||
0;
|
||||
|
||||
private static Guid StableKeepsakeInstanceId(long id)
|
||||
{
|
||||
var hash = MD5.HashData(Encoding.UTF8.GetBytes($"keepsakeInstance:{id}"));
|
||||
return new Guid(hash);
|
||||
}
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
public sealed class KeepsakeRequest
|
||||
{
|
||||
// RecNet.AddKeepsakeInstanceRequest (2023 client).
|
||||
public long? RoomId { get; set; }
|
||||
public long? SubRoomId { get; set; }
|
||||
public int? KeepsakeCategory { get; set; }
|
||||
|
||||
public string? Category { get; set; }
|
||||
public string? EventKey { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public DateTime? EarnedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,106 @@ public class LeaderboardController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/leaderboard/CheckAndSetStat</c> — the compare-and-set
|
||||
/// form of <see cref="SetStat"/>, and the ONLY stat-write route the
|
||||
/// 2023-03-21 client has: that build contains no <c>SetStat</c> literal at
|
||||
/// all. Nothing registered this route, so every leaderboard score written
|
||||
/// by a 2023 client fell through to the catch-all and was lost.
|
||||
///
|
||||
/// The request is <c>RecNet.SetStatRequestDTO</c>, whose property names are
|
||||
/// NOT obfuscated in the dump: <c>StatChannel</c> (Int32), <c>RoomId</c>
|
||||
/// (Int64), <c>StatValue</c> (Int32) and the extra
|
||||
/// <c>CurrentStatValue</c> (Int32?) that distinguishes this route from
|
||||
/// plain SetStat.
|
||||
///
|
||||
/// <c>CurrentStatValue</c> is the value the client believes is currently
|
||||
/// stored. It is an optimistic-concurrency guard: the write applies only if
|
||||
/// the server agrees, which stops a stale session from clobbering a newer
|
||||
/// score. A null means "no expectation" and writes unconditionally, and a
|
||||
/// mismatch is reported rather than silently dropped so the client can
|
||||
/// re-read.
|
||||
///
|
||||
/// As with SetStat, the client has already decided the new value should win
|
||||
/// (it applies the per-channel higher/lower-is-better rule locally), so the
|
||||
/// server does not second-guess the direction.</summary>
|
||||
[HttpPost("/leaderboard/CheckAndSetStat")]
|
||||
[HttpPost("/api/leaderboard/CheckAndSetStat")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheckAndSetStat([FromBody] CheckAndSetStatRequest req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
|
||||
if (req.CurrentStatValue is int expected && row is not null && row.Value != expected)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
error = "stat_value_mismatch",
|
||||
StatValue = row.Value,
|
||||
CurrentStatValue = row.Value,
|
||||
});
|
||||
}
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new LeaderboardStatEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
RoomId = roomId,
|
||||
StatChannel = req.StatChannel,
|
||||
Value = req.StatValue,
|
||||
};
|
||||
db.LeaderboardStats.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = req.StatValue;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await EnsureChannelMetaAsync(roomId, req.StatChannel);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
error = "",
|
||||
StatValue = row.Value,
|
||||
CurrentStatValue = row.Value,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class CheckAndSetStatRequest
|
||||
{
|
||||
public int StatChannel { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int StatValue { get; set; }
|
||||
|
||||
/// <summary>The value the client believes is stored. Null = write
|
||||
/// unconditionally.</summary>
|
||||
public int? CurrentStatValue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stamp a placeholder channel-meta row the first time a channel
|
||||
/// reports, so the admin SPA's per-room Leaderboards tab surfaces it.</summary>
|
||||
private async Task EnsureChannelMetaAsync(long roomId, int statChannel)
|
||||
{
|
||||
if (roomId <= 0) return;
|
||||
var meta = await db.LeaderboardChannelMeta
|
||||
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == statChannel);
|
||||
if (meta is not null) return;
|
||||
db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity
|
||||
{
|
||||
Channel = statChannel,
|
||||
RoomId = roomId,
|
||||
Name = $"Channel {statChannel}",
|
||||
LowerIsBetter = false,
|
||||
ValueFormat = "count",
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class GetPlayerRankRequest
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -105,9 +106,23 @@ public class InGameModerationController(
|
||||
|
||||
// ── /v1/instantKick — admin one-shot ─────────────────────────────
|
||||
/// <summary>
|
||||
/// Admin/dev one-shot kick. The watch posts either
|
||||
/// Admin/dev one-shot kick. The 2020.12 watch posts either
|
||||
/// <c>?playerId=N</c> for a single target (PlayerReporting.txt:3417)
|
||||
/// or a list body for bulk kicks (3590). Either form lands here.
|
||||
/// or a list body for bulk kicks (3590).
|
||||
///
|
||||
/// The 2023-03-21 client posts neither: it serialises a
|
||||
/// <c>RecNet.KickPlayerDTO</c> with <c>JsonUtility.ToJson</c> and sends it
|
||||
/// as a RAW JSON body (RecNet.Runtime/FPIBGPIAOBI.txt:5012 JsonUtility.ToJson,
|
||||
/// :5018 BNDIAONDFFF.FJLLPHFOOJJ raw-body setter, :5034 verb rdx=2).
|
||||
/// KickPlayerDTO's fields are not obfuscated — <c>public long GameSessionId</c>
|
||||
/// and <c>public List<int> PlayerIds</c> — so the body is exactly
|
||||
/// <c>{"GameSessionId":123,"PlayerIds":[456]}</c>. Unity's JsonUtility emits
|
||||
/// field names verbatim.
|
||||
///
|
||||
/// None of the query/form parameters bound against that body, so the target
|
||||
/// list came back empty and every 2023 instant-kick returned
|
||||
/// "missing_target" and kicked nobody.
|
||||
///
|
||||
/// Gated to <see cref="PlayerEntity.IsAdmin"/> /
|
||||
/// <see cref="PlayerEntity.IsDeveloper"/>.
|
||||
/// </summary>
|
||||
@@ -130,6 +145,7 @@ public class InGameModerationController(
|
||||
foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
ids.AddRange(await ReadKickPlayerDtoIdsAsync());
|
||||
ids = ids.Distinct().ToList();
|
||||
if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" });
|
||||
|
||||
@@ -143,6 +159,47 @@ public class InGameModerationController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>Read the 2023 client's raw <c>KickPlayerDTO</c> JSON body.
|
||||
/// Returns an empty list for a form post or an unparseable body so the
|
||||
/// older query/form paths are unaffected.</summary>
|
||||
private async Task<List<long>> ReadKickPlayerDtoIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
if (Request.HasFormContentType) return ids;
|
||||
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
Request.Body.Position = 0;
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return ids;
|
||||
|
||||
foreach (var name in new[] { "PlayerIds", "playerIds" })
|
||||
{
|
||||
if (!doc.RootElement.TryGetProperty(name, out var arr) ||
|
||||
arr.ValueKind != JsonValueKind.Array) continue;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0)
|
||||
ids.Add(v);
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var name in new[] { "PlayerId", "playerId" })
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty(name, out var one) &&
|
||||
one.ValueKind == JsonValueKind.Number &&
|
||||
one.TryGetInt64(out var v) && v > 0)
|
||||
{
|
||||
ids.Add(v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* not a JSON body */ }
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ── /v1/deviceId — log mismatched device id ──────────────────────
|
||||
[HttpPost("api/PlayerReporting/v1/deviceId")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
@@ -192,11 +249,21 @@ public class InGameModerationController(
|
||||
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
|
||||
caller, target, reason, p.BannedUntil, displayReason);
|
||||
|
||||
// Push a ModerationRoomBan notification so the target watch
|
||||
// shows the ban dialog and disconnects (PlayerReporting.txt:2253).
|
||||
await notifications.NotifyAsync(
|
||||
// Push the ban as a ModerationKick (22) with IsBan set, NOT as
|
||||
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager
|
||||
// registers only 22, "ModerationUnkick" and 23
|
||||
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler
|
||||
// for 24, so that push landed nowhere and the banned player kept playing
|
||||
// until their next login. The 22 handler reads Msg.Reason as a
|
||||
// ModerationBlockDetail, which ModerationKickPayload already produces.
|
||||
await notifications.KickPlayerAsync(
|
||||
target,
|
||||
Models.Notification.PushNotificationId.ModerationRoomBan);
|
||||
// `reason` is the numeric report category; DisplayReason is the
|
||||
// moderator's text and is what the ban dialog shows.
|
||||
string.IsNullOrWhiteSpace(displayReason)
|
||||
? "You have been banned from this room."
|
||||
: displayReason.Trim(),
|
||||
isBan: true);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase
|
||||
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
|
||||
{
|
||||
error = "use_signalr_hub",
|
||||
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
|
||||
hint = $"connect to {domain.Url("notify", "/hub/v1")} instead",
|
||||
});
|
||||
|
||||
/// <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")]
|
||||
public IActionResult HubInfo() => Ok(new
|
||||
{
|
||||
Url = $"https://{domain.Sub("notify")}/hub/v1",
|
||||
Url = domain.Url("notify", "/hub/v1"),
|
||||
Protocol = "signalr",
|
||||
Version = 1,
|
||||
});
|
||||
|
||||
@@ -41,9 +41,9 @@ public class PageviewController(ILogger<PageviewController> logger) : Controller
|
||||
/// 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")]
|
||||
[HttpPost("pageview/consume", Order = 10)]
|
||||
[HttpPost("/pageview/consume")]
|
||||
[HttpGet("pageview/consume")]
|
||||
[HttpGet("pageview/consume", Order = 10)]
|
||||
[HttpGet("/pageview/consume")]
|
||||
public IActionResult Consume()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -28,7 +30,6 @@ namespace DorkNet.Server.Controllers.API.Photos;
|
||||
public class PhotosController(
|
||||
DorkNetDbContext db,
|
||||
PlayerPresenceService presence,
|
||||
NotificationService notifications,
|
||||
LevelService level,
|
||||
DomainConfig domain) : ControllerBase
|
||||
{
|
||||
@@ -83,12 +84,11 @@ public class PhotosController(
|
||||
// Posting a photo gives a small XP bump — encourages camera use.
|
||||
await level.AwardXpAsync(me, LevelService.InventionSavedXp, $"photo_posted:{photo.Id}");
|
||||
|
||||
// Push to tagged players so their "Photos of me" feed refreshes.
|
||||
foreach (var taggedId in ParseTagged(photo.TaggedPlayerIdsCsv))
|
||||
await notifications.NotifyAsync(taggedId,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "TaggedInPhoto", PhotoId = photo.Id, From = me });
|
||||
|
||||
// No real-time push for photo tags: there is no matching client
|
||||
// message type, and the old SubscriptionUpdateProfile
|
||||
// {Reason:"TaggedInPhoto"} blob decoded to a blank account
|
||||
// ("Orphan player account (0)") without refreshing anything. The
|
||||
// "Photos of me" feed refreshes on the tagged player's next fetch.
|
||||
return Ok(ToDto(photo));
|
||||
}
|
||||
|
||||
@@ -217,9 +217,9 @@ public class PhotosController(
|
||||
{
|
||||
await level.AwardXpAsync(photo.UploaderPlayerId,
|
||||
LevelService.CheerReceivedXp, $"photo_cheer:{id}");
|
||||
await notifications.NotifyAsync(photo.UploaderPlayerId,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "PhotoCheer", PhotoId = id, From = me });
|
||||
// No real-time push: no client message type for photo cheers, and
|
||||
// the old SubscriptionUpdateProfile blob only spawned a blank
|
||||
// "Orphan player account (0)". Count refreshes on next fetch.
|
||||
}
|
||||
return Ok(new { cheered = true, count = photo.CheerCount });
|
||||
}
|
||||
@@ -242,6 +242,318 @@ public class PhotosController(
|
||||
return Ok(new { deleted = id });
|
||||
}
|
||||
|
||||
// ── 2023 home feed (Discovery host) ───────────────────────────────
|
||||
|
||||
/// <summary>The only <c>FeedItemType</c> (client enum
|
||||
/// <c>DAAEHNDDCCH</c>) the 2023 feed list can render: an image.
|
||||
/// Two independent confirmations in the binary — the list spawner
|
||||
/// builds one <c>RRUI.Data.ImageFeedItemModel</c> per returned item
|
||||
/// (<c>Assembly-CSharp/RRUI/Data/FeedListModelController_NestedType_FeedListSpawnerImpl.txt:294</c>
|
||||
/// "044 Call ImageFeedItemModel.Set"), and the share deep-link
|
||||
/// handler maps the path segment "image" + an Int64 id onto an
|
||||
/// injected feed item constructed with the enum literal 0
|
||||
/// (<c>Assembly-CSharp/RecRoom/Sharing/LinkManager.txt:5646</c>
|
||||
/// Move rdx,"image" … :5665 Move rdx,0 … :5668 Call
|
||||
/// EKLOCIPFHII..ctor). FeedItemId is therefore an image id, which on
|
||||
/// DorkNet is <see cref="PhotoEntity.Id"/> — the same id space
|
||||
/// <c>api/images/v5/bulk</c> resolves.</summary>
|
||||
private const int FeedItemTypeImage = 0;
|
||||
|
||||
/// <summary>Ranking revision stamped on the envelope and on every
|
||||
/// item. The client never interprets it: it copies the Int16 off the
|
||||
/// response into the list model and replays it in the impression
|
||||
/// telemetry it later posts back (<c>FeedListModel_NestedType_OLMJPGEMAKM.txt</c>
|
||||
/// "031 Move rax,[rdi+40] / 032 Move [rcx+280],rax" then
|
||||
/// "040 Call FeedListModel.LMDNOKJCBGE"). Bump it when the ordering
|
||||
/// below changes so the telemetry can tell the cohorts apart.</summary>
|
||||
private const short FeedAlgoVersion = 1;
|
||||
|
||||
/// <summary>POST /feed/query — the 2023 client's personalised home
|
||||
/// feed. Host is Discovery (host enum 19) and the verb is POST:
|
||||
/// <c>RecNet.Runtime/KGLCPCEGCFF.txt:166-178</c> loads
|
||||
/// r9 = "feed/query", r8 = 19, rdx = 2 (BestHTTP POST) into the
|
||||
/// shared BNDIAONDFFF request ctor. The body is a RAW JSON document
|
||||
/// (the issuing method takes an already-serialised
|
||||
/// <c>System.String</c> and hands it to BNDIAONDFFF.FJLLPHFOOJJ),
|
||||
/// NOT form fields — so it is read straight off Request.Body.
|
||||
///
|
||||
/// Request keys (reader BAEBDMPPKKE.txt:331-414, each registered in
|
||||
/// PascalCase/camelCase/lowercase): Take:Int32,
|
||||
/// ContinuationToken:String?, InjectedFeedItems:[{FeedItemType,
|
||||
/// FeedItemId}], FeedInstanceId:String?.
|
||||
///
|
||||
/// Response is the generic result envelope AECMPGPHAII<T>
|
||||
/// {Success, Error, Value} (keys at AEGADMDIKNE.txt:243-286) wrapping
|
||||
/// BDIFPHKLFKD {Items, FeedInstanceId, FeedContext,
|
||||
/// FeedAlgorithmVersion} (keys at EHKEOCKBCDH.txt:331-414). Anything
|
||||
/// else — including a 404 — rejects the promise and the home tab
|
||||
/// renders its failure state.
|
||||
///
|
||||
/// FeedContext is the continuation cursor, not a display string: the
|
||||
/// RefreshPage continuation reads field +32 (FeedContext) as the
|
||||
/// third element of its (count, items, nextToken) tuple and feeds it
|
||||
/// back as the next request's ContinuationToken
|
||||
/// (<c>FeedListModel_NestedType_OLMJPGEMAKM.txt</c> "062 Move r9,
|
||||
/// [rdi+32]"). An empty FeedContext therefore ends pagination.
|
||||
///
|
||||
/// Anonymous-safe like the other feed reads; when the caller is
|
||||
/// authenticated the ordering is personalised (friends/subscriptions
|
||||
/// first) and blocked/ignored uploaders are filtered out.</summary>
|
||||
[HttpPost("/feed/query")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> FeedQuery()
|
||||
{
|
||||
var take = 20;
|
||||
string? continuationToken = null;
|
||||
string? requestedInstanceId = null;
|
||||
var requestedInjected = new List<long>();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (TryFeedProp(root, "Take", out var takeEl) &&
|
||||
takeEl.ValueKind == JsonValueKind.Number && takeEl.TryGetInt32(out var t))
|
||||
take = t;
|
||||
if (TryFeedProp(root, "ContinuationToken", out var ctEl) &&
|
||||
ctEl.ValueKind == JsonValueKind.String)
|
||||
continuationToken = ctEl.GetString();
|
||||
if (TryFeedProp(root, "FeedInstanceId", out var fiEl) &&
|
||||
fiEl.ValueKind == JsonValueKind.String)
|
||||
requestedInstanceId = fiEl.GetString();
|
||||
if (TryFeedProp(root, "InjectedFeedItems", out var injEl) &&
|
||||
injEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var entry in injEl.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind != JsonValueKind.Object) continue;
|
||||
// Non-image injected items can't be rendered by the
|
||||
// 2023 list, so echoing them back would produce a
|
||||
// blank card. Drop them.
|
||||
var type = TryFeedProp(entry, "FeedItemType", out var tyEl) &&
|
||||
tyEl.ValueKind == JsonValueKind.Number &&
|
||||
tyEl.TryGetInt32(out var ty)
|
||||
? ty : FeedItemTypeImage;
|
||||
if (type != FeedItemTypeImage) continue;
|
||||
if (TryFeedProp(entry, "FeedItemId", out var idEl) &&
|
||||
idEl.ValueKind == JsonValueKind.Number &&
|
||||
idEl.TryGetInt64(out var id) && id > 0)
|
||||
requestedInjected.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Unparseable/empty body — treat as a default first-page pull
|
||||
// rather than 400, so a malformed retry still shows content.
|
||||
}
|
||||
|
||||
take = Math.Clamp(take, 1, 50);
|
||||
var me = this.CurrentPlayerId();
|
||||
|
||||
// Uploaders the caller can't see: anyone they blocked or ignored,
|
||||
// plus anyone who blocked them.
|
||||
var hidden = new List<long>();
|
||||
var affinity = new List<long>();
|
||||
if (me is long meId)
|
||||
{
|
||||
hidden = await db.Relationships
|
||||
.Where(r => (r.RequesterId == meId &&
|
||||
(r.Status == RelationshipStatus.Blocked || r.Ignored)) ||
|
||||
(r.TargetId == meId && r.Status == RelationshipStatus.Blocked))
|
||||
.Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
var friends = await db.Relationships
|
||||
.Where(r => r.Status == RelationshipStatus.Friend &&
|
||||
(r.RequesterId == meId || r.TargetId == meId))
|
||||
.Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId)
|
||||
.ToListAsync();
|
||||
var follows = await db.Subscriptions
|
||||
.Where(s => s.SubscriberPlayerId == meId)
|
||||
.Select(s => s.TargetPlayerId)
|
||||
.ToListAsync();
|
||||
affinity = friends.Concat(follows)
|
||||
.Where(id => id > 0 && !hidden.Contains(id))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var resumed = TryDecodeFeedCursor(continuationToken,
|
||||
out var phase, out var cursorTicks, out var cursorId, out var carriedInjected);
|
||||
|
||||
// Injected items are pinned to the head of the FIRST page only, in
|
||||
// the order the client asked for them (they come from a share
|
||||
// deep-link the player just followed). Their ids ride along in the
|
||||
// cursor so later pages don't repeat them organically.
|
||||
var injectedIds = resumed ? carriedInjected : requestedInjected.Distinct().ToArray();
|
||||
var itemIds = new List<long>();
|
||||
if (!resumed && injectedIds.Length > 0)
|
||||
{
|
||||
var pinned = await VisibleFeedPhotos(hidden)
|
||||
.Where(p => injectedIds.Contains(p.Id))
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync();
|
||||
itemIds.AddRange(injectedIds.Where(pinned.Contains));
|
||||
}
|
||||
|
||||
DateTime? cutoffAt = resumed && cursorTicks > 0 && cursorTicks <= DateTime.MaxValue.Ticks
|
||||
? new DateTime(cursorTicks)
|
||||
: (DateTime?)null;
|
||||
var cutoffId = resumed ? cursorId : 0L;
|
||||
if (phase < 0) phase = 0;
|
||||
|
||||
// Phase 0 = photos from people the caller follows or is friends
|
||||
// with; phase 1 = everyone else. Both are newest-first, and the
|
||||
// cursor is (CreatedAt, Id) so a photo posted mid-scroll can never
|
||||
// shift a row onto a page the client already consumed.
|
||||
while (itemIds.Count < take && phase <= 1)
|
||||
{
|
||||
if (phase == 0 && affinity.Count == 0)
|
||||
{
|
||||
phase = 1;
|
||||
cutoffAt = null;
|
||||
cutoffId = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
var need = take - itemIds.Count;
|
||||
var q = VisibleFeedPhotos(hidden);
|
||||
if (injectedIds.Length > 0)
|
||||
q = q.Where(p => !injectedIds.Contains(p.Id));
|
||||
q = phase == 0
|
||||
? q.Where(p => affinity.Contains(p.UploaderPlayerId))
|
||||
: q.Where(p => !affinity.Contains(p.UploaderPlayerId));
|
||||
if (cutoffAt is DateTime at)
|
||||
{
|
||||
var atId = cutoffId;
|
||||
q = q.Where(p => p.CreatedAt < at || (p.CreatedAt == at && p.Id < atId));
|
||||
}
|
||||
|
||||
var batch = await q
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.ThenByDescending(p => p.Id)
|
||||
.Take(need)
|
||||
.Select(p => new { p.Id, p.CreatedAt })
|
||||
.ToListAsync();
|
||||
itemIds.AddRange(batch.Select(b => b.Id));
|
||||
|
||||
if (batch.Count < need)
|
||||
{
|
||||
phase++;
|
||||
cutoffAt = null;
|
||||
cutoffId = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
cutoffAt = batch[^1].CreatedAt;
|
||||
cutoffId = batch[^1].Id;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty FeedContext = end of the feed; the client stops paging.
|
||||
var nextContext = phase > 1
|
||||
? string.Empty
|
||||
: EncodeFeedCursor(phase, cutoffAt?.Ticks ?? 0, cutoffId, injectedIds);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = (string?)null,
|
||||
Value = new
|
||||
{
|
||||
Items = itemIds.Select(id => new
|
||||
{
|
||||
FeedItemType = FeedItemTypeImage,
|
||||
FeedItemId = id,
|
||||
FeedAlgorithmVersion = FeedAlgoVersion,
|
||||
}).ToList(),
|
||||
// The client sends the instance id it generated for this
|
||||
// scroll session and stores whatever comes back; echoing it
|
||||
// keeps its impression telemetry stitched to one session.
|
||||
FeedInstanceId = string.IsNullOrWhiteSpace(requestedInstanceId)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: requestedInstanceId,
|
||||
FeedContext = nextContext,
|
||||
FeedAlgorithmVersion = FeedAlgoVersion,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Photos eligible for the public home feed: live, public,
|
||||
/// and not from an uploader the caller blocked/ignored.</summary>
|
||||
private IQueryable<PhotoEntity> VisibleFeedPhotos(List<long> hidden)
|
||||
{
|
||||
var q = db.Photos.Where(p => p.IsPublic && p.DeletedAt == null);
|
||||
if (hidden.Count > 0)
|
||||
q = q.Where(p => !hidden.Contains(p.UploaderPlayerId));
|
||||
return q;
|
||||
}
|
||||
|
||||
/// <summary>Case-insensitive property lookup — the 2023 generated
|
||||
/// writers emit PascalCase but their readers register all three
|
||||
/// casings, so tolerate whatever the client sends.</summary>
|
||||
private static bool TryFeedProp(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Opaque base64url cursor handed to the client as
|
||||
/// FeedContext. Carries the phase, the (CreatedAt, Id) keyset
|
||||
/// position within that phase, and the pinned injected ids so they
|
||||
/// stay suppressed on later pages.</summary>
|
||||
private static string EncodeFeedCursor(int phase, long ticks, long lastId, IReadOnlyList<long> injected)
|
||||
{
|
||||
var raw = $"1|{phase}|{ticks}|{lastId}|{string.Join(',', injected)}";
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
private static bool TryDecodeFeedCursor(string? token, out int phase,
|
||||
out long ticks, out long lastId, out long[] injected)
|
||||
{
|
||||
phase = 0;
|
||||
ticks = 0;
|
||||
lastId = 0;
|
||||
injected = Array.Empty<long>();
|
||||
if (string.IsNullOrWhiteSpace(token)) return false;
|
||||
try
|
||||
{
|
||||
var b64 = token.Replace('-', '+').Replace('_', '/');
|
||||
b64 = b64.PadRight(b64.Length + ((4 - (b64.Length % 4)) % 4), '=');
|
||||
var parts = Encoding.UTF8.GetString(Convert.FromBase64String(b64)).Split('|');
|
||||
if (parts.Length != 5 || parts[0] != "1") return false;
|
||||
if (!int.TryParse(parts[1], out phase)) return false;
|
||||
if (!long.TryParse(parts[2], out ticks)) return false;
|
||||
if (!long.TryParse(parts[3], out lastId)) return false;
|
||||
injected = parts[4]
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||
.Where(v => v > 0)
|
||||
.ToArray();
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Garbage/stale cursor — restart the feed from the top instead
|
||||
// of failing the whole pull.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Trim/dedupe tagged ids and drop the uploader's own id.
|
||||
|
||||
@@ -114,7 +114,18 @@ public class PlatformLoginController(
|
||||
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
|
||||
[HttpDelete("cached")]
|
||||
[HttpDelete("cachedlogins")]
|
||||
public IActionResult RemoveCachedLogin() => Ok(new { });
|
||||
public async Task<IActionResult> RemoveCachedLogin([FromQuery] long? accountId = null)
|
||||
{
|
||||
var targetId = accountId ?? this.CurrentPlayerId();
|
||||
if (targetId is not long playerId) return Unauthorized();
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null) return NotFound();
|
||||
player.LastPlatform = 0;
|
||||
player.LastPlatformId = string.Empty;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, AccountId = (int)player.Id });
|
||||
}
|
||||
|
||||
// Login.RefreshLogin → POST api/platformlogin/refresh
|
||||
// Response is Login.RefreshLoginResponse { Token } — single field, the
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayStationPlus;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayStationPlusController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/playstationplus/membership")]
|
||||
public async Task<IActionResult> Membership()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
|
||||
var expires = row is not null && DateTime.TryParse(row.Value, out var parsed)
|
||||
? parsed
|
||||
: DateTime.MinValue;
|
||||
var active = expires > DateTime.UtcNow;
|
||||
return Ok(new
|
||||
{
|
||||
IsMember = active,
|
||||
MembershipActive = active,
|
||||
ExpiresAt = active ? expires : (DateTime?)null,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("api/playstationplus/expire")]
|
||||
public async Task<IActionResult> Expire()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "playstationplus:expires",
|
||||
Value = DateTime.UtcNow.AddSeconds(-1).ToString("O"),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = DateTime.UtcNow.AddSeconds(-1).ToString("O");
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, IsMember = false });
|
||||
}
|
||||
}
|
||||
@@ -20,46 +20,83 @@ namespace DorkNet.Server.Controllers.API.PlayerCheer;
|
||||
[Authorize]
|
||||
public class PlayerCheerController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
LevelService level) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers
|
||||
/// the target player. <c>Type</c> matches the same enum as
|
||||
/// <c>CheerEntity.Type</c>. Idempotent on (caller, target, type)
|
||||
/// to match <c>PlayerStateController.CheerPlayer</c>, so the same
|
||||
/// category click doesn't inflate the count.</summary>
|
||||
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers the
|
||||
/// target player. Request fields (verified from RecNet.Runtime
|
||||
/// KFJKDMGHKHE.EMKBFCLIOGN, KFJKDMGHKHE.txt:696-743, all posted via
|
||||
/// BestHTTP AddField → form): <c>PlayerIdTo</c> (target account id),
|
||||
/// <c>CheerCategory</c> (the cheer enum), <c>RoomId</c> (optional room
|
||||
/// context), <c>Anonymous</c> (bool). The OLD binding read
|
||||
/// <c>TargetAccountId</c>/<c>Type</c>, which never matched — the target
|
||||
/// bound to null, the handler reported failure, and the watch surfaced
|
||||
/// "Failed to cheer player — Something went wrong". The client parses the
|
||||
/// response into PHMHCPEMABG { bool, string } and gates on the bool, so
|
||||
/// the body must report success. Idempotent on (caller, target, room,
|
||||
/// category).</summary>
|
||||
[HttpPost("api/PlayerCheer/v1/create")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> Create(
|
||||
[FromForm(Name = "TargetAccountId")] long? targetAccountId,
|
||||
[FromForm(Name = "Type")] int type = 0)
|
||||
[FromForm(Name = "PlayerIdTo")] long? playerIdTo,
|
||||
[FromForm(Name = "CheerCategory")] int cheerCategory = 0,
|
||||
[FromForm(Name = "RoomId")] long? roomId = null,
|
||||
[FromForm(Name = "Anonymous")] bool anonymous = false)
|
||||
{
|
||||
if (targetAccountId is not long target || target <= 0)
|
||||
return Ok(new { success = false, error = "missing_target" });
|
||||
if (playerIdTo is not long target || target <= 0)
|
||||
return CheerResult(false, "Invalid cheer target.");
|
||||
if (Me == target)
|
||||
return Ok(new { success = false, error = "cannot_cheer_self" });
|
||||
return CheerResult(false, "You can't cheer yourself.");
|
||||
|
||||
var targetRoomId = roomId ?? 0;
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == Me && c.TargetPlayerId == target &&
|
||||
c.TargetRoomId == 0 && c.Type == type);
|
||||
if (existing is not null) return Ok(new { success = true, already_cheered = true });
|
||||
c.TargetRoomId == targetRoomId && c.Type == cheerCategory);
|
||||
if (existing is not null)
|
||||
// Already cheered — still a success from the client's POV (the
|
||||
// string carries the client's own "very recently" phrasing).
|
||||
return CheerResult(true, "You have already cheered this player very recently.");
|
||||
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
FromPlayerId = Me,
|
||||
TargetPlayerId = target,
|
||||
Type = type,
|
||||
TargetRoomId = targetRoomId,
|
||||
Type = cheerCategory,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
|
||||
await notifications.NotifyAsync(target,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "CheerReceived", From = Me, Type = type });
|
||||
return Ok(new { success = true, error = "" });
|
||||
// Real notification: a PlayerCheer message the recipient renders as
|
||||
// "X cheered you". Anonymous cheers use the anonymous message type and
|
||||
// don't reveal the cheerer.
|
||||
await systemNotifications.SendAsync(target,
|
||||
anonymous
|
||||
? SystemNotificationService.MessageType.PlayerCheerAnonymous
|
||||
: SystemNotificationService.MessageType.PlayerCheer,
|
||||
fromPlayerId: anonymous ? 0 : Me);
|
||||
return CheerResult(true, "");
|
||||
}
|
||||
|
||||
/// <summary>The create response deserializes into PHMHCPEMABG
|
||||
/// { bool GJLFIFEJDEH, string ABGLLJPIMIO } (KFJKDMGHKHE.txt maps the raw
|
||||
/// body via Func<PHMHCPEMABG, LDGADANDBIO>). The JSON keys are
|
||||
/// obfuscated with no literals in the dump, so emit the success bool and
|
||||
/// message under the common key spellings via a Dictionary (literal keys
|
||||
/// bypass the camelCase policy; LitJson ignores the ones it doesn't
|
||||
/// read).</summary>
|
||||
private IActionResult CheerResult(bool success, string message) =>
|
||||
Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["Success"] = success,
|
||||
["success"] = success,
|
||||
["Message"] = message,
|
||||
["message"] = message,
|
||||
["Error"] = success ? string.Empty : message,
|
||||
["error"] = success ? string.Empty : message,
|
||||
});
|
||||
|
||||
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
|
||||
/// a cheer category as the caller's selected badge. Stored on the
|
||||
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
@@ -10,25 +12,49 @@ using DorkNet.Server.Services;
|
||||
namespace DorkNet.Server.Controllers.API.PlayerEvents;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for
|
||||
/// player-scheduled gatherings.
|
||||
/// api.rec.net/api/playerevents/v1|v2/* — create, browse, edit, RSVP and
|
||||
/// broadcast for player-scheduled gatherings.
|
||||
///
|
||||
/// The 2023-03-21 client's event API surface is <c>RecNet.Runtime/CBKANFIOBCF</c>
|
||||
/// (route literals at <c>CBKANFIOBCF.txt:161-4156</c>). Three wire types drive
|
||||
/// nearly every handler here:
|
||||
/// * <b>HPIOAGDJHDH</b> (PlayerEvent) — 17 keys, formatter
|
||||
/// <c>HPMMKFGDAEC.txt:1139-1518</c>.
|
||||
/// * <b>MDCBEPJCJPO</b> (PlayerEvent + <c>Tags:[{Tag,Type}]</c>) — formatter
|
||||
/// <c>CENEMCMGDKG.txt:1191-1586</c>, tag entry keys
|
||||
/// <c>PCMLHLIBLNJ.txt:191-218</c>.
|
||||
/// * <b>PHHAKLPGNGC</b> (<c>{PlayerEvent,Result,TagModifyResult}</c>) —
|
||||
/// formatter <c>EEAAGIJHLOJ.txt:267-326</c>, <c>TagModifyResult</c> keys
|
||||
/// <c>ALJPHDEAHBK.txt:191-218</c>. Every v2 create/edit/delete/field
|
||||
/// mutation deserialises this wrapper, so a flat event body leaves the
|
||||
/// client's <c>PlayerEvent</c> null after a save.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayerEventsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications) : ControllerBase
|
||||
NotificationService notifications,
|
||||
PlayerPresenceService presence) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
// ── Browse (v2) ──────────────────────────────────────────────────────
|
||||
/// <summary>PlayerEventResponseType.NotGoing — the one RSVP state that
|
||||
/// does not count towards AttendeeCount.</summary>
|
||||
private const int NotGoingResponse = 2;
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v2</c> — upcoming events list.
|
||||
/// Returns the next 50 events ordered by start time. Wire shape
|
||||
/// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId,
|
||||
/// Title, Description, StartsAt, EndsAt, Capacity).</summary>
|
||||
// ── Browse ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v2</c> (and <c>v1</c>) — upcoming
|
||||
/// events list, a BARE JSON array of PlayerEvent.
|
||||
///
|
||||
/// The 2023 watch's Events tab reads the unversioned path: HIHEFBMPOGC
|
||||
/// returns <c>List<HPIOAGDJHDH></c> and dispatches with verb 0
|
||||
/// (<c>CBKANFIOBCF.txt:1305</c>, route literal <c>:1304</c>). Only
|
||||
/// <c>[HttpPost]</c> was registered on <c>api/playerevents/v1</c>, so the
|
||||
/// whole tab 405'd.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2")]
|
||||
[HttpGet("api/playerevents/v1")]
|
||||
public async Task<ActionResult> ListUpcoming(
|
||||
[FromQuery] int skip = 0, [FromQuery] int take = 50)
|
||||
{
|
||||
@@ -40,22 +66,221 @@ public class PlayerEventsController(
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1|v2/{id}</c>. The client calls this
|
||||
/// three ways — plain (NLEFILEAMIP → HPIOAGDJHDH) and with
|
||||
/// <c>?includeDetails=</c> / <c>?clubId=</c> (HLJBCGMONEH / OAKKDBLNKLG →
|
||||
/// MDCBEPJCJPO, query keys at
|
||||
/// <c>CBKANFIOBCF_NestedType_DEIBMMABHIK.txt:86,99</c>). Both DTOs are the
|
||||
/// same object, the detail one just adds <c>Tags</c>, and the Utf8Json
|
||||
/// formatters skip unknown keys — so we always emit the superset and no
|
||||
/// branch is needed.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2/{eventId:long}")]
|
||||
[HttpGet("api/playerevents/v1/{eventId:long}")]
|
||||
public async Task<ActionResult> GetOne(long eventId)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
if (ev is null) return NotFound();
|
||||
return Ok(ToWire(ev));
|
||||
return Ok(await ToWireOneAsync(ev));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/search</c>. Query keys are
|
||||
/// <c>query</c>, <c>sort</c> and <c>scheduleFilter</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_IONCOJJGHNJ.txt:99,113,126</c>); the latter
|
||||
/// two are enum ordinals (PJBLEKMMACM / BJBLPLKMLBE).</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/search")]
|
||||
public async Task<ActionResult> Search(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int? sort = null,
|
||||
[FromQuery] int? scheduleFilter = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var rows = ApplyScheduleFilter(db.PlayerEvents.AsQueryable(), scheduleFilter);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
rows = rows.Where(e =>
|
||||
e.Title.ToLower().Contains(needle) ||
|
||||
e.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var result = await ApplySort(rows, sort)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(await ToWireManyAsync(result));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/searchlive</c> — "happening now".
|
||||
/// Separate handler from <see cref="Search"/> because the response element
|
||||
/// type differs: FIKHCEPDIBF returns <c>List<CLDDIKOJMAM></c>, which
|
||||
/// is the PlayerEvent object plus <c>PlayerCount</c> (Int32) and
|
||||
/// <c>IsFull</c> (Boolean) — formatter <c>PDIFGFKAMBG.txt:1267,1294</c>.
|
||||
/// Head-count comes from the room's live GameSessions rows (their RoomId is
|
||||
/// the numeric room id as a string, see
|
||||
/// <c>Services/GameSessionService.cs:91-96</c>).</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/searchlive")]
|
||||
public async Task<ActionResult> SearchLive(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var live = db.PlayerEvents.Where(e => e.StartsAt <= now && e.EndsAt > now);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
live = live.Where(e =>
|
||||
e.Title.ToLower().Contains(needle) ||
|
||||
e.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var rows = await live.OrderBy(e => e.StartsAt).Take(take).ToListAsync();
|
||||
var wire = await ToWireManyAsync(rows);
|
||||
|
||||
var roomKeys = rows.Select(r => r.RoomId.ToString()).Distinct().ToList();
|
||||
var sessions = await db.GameSessions
|
||||
.Where(s => roomKeys.Contains(s.RoomId))
|
||||
.GroupBy(s => s.RoomId)
|
||||
.Select(g => new
|
||||
{
|
||||
RoomId = g.Key,
|
||||
Players = g.Sum(s => s.PlayerCount),
|
||||
Capacity = g.Sum(s => s.MaxCapacity),
|
||||
})
|
||||
.ToListAsync();
|
||||
var byRoom = sessions.ToDictionary(s => s.RoomId, s => s);
|
||||
|
||||
for (var i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var players = 0;
|
||||
var capacity = 0;
|
||||
if (byRoom.TryGetValue(rows[i].RoomId.ToString(), out var agg))
|
||||
{
|
||||
players = agg.Players;
|
||||
capacity = agg.Capacity;
|
||||
}
|
||||
wire[i]["PlayerCount"] = players;
|
||||
wire[i]["IsFull"] = capacity > 0 && players >= capacity;
|
||||
}
|
||||
|
||||
return Ok(wire);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/room/{roomId:long}")]
|
||||
public async Task<ActionResult> ForRoom(long roomId)
|
||||
{
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => e.RoomId == roomId && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/club/{clubId}</c>. PHNEDJAPIKI
|
||||
/// returns IOKLNPFOLGI — an OBJECT
|
||||
/// <c>{Events:[PlayerEvent],ContinuationToken:String}</c>
|
||||
/// (<c>GHBIGGJKPFI.txt:203-238</c>), not a bare array, and passes
|
||||
/// <c>take</c> / <c>continuationToken</c> query params
|
||||
/// (<c>CBKANFIOBCF_NestedType_PKDDOCBDBIM.txt:80,89</c>). The token here is
|
||||
/// just the next skip offset; empty string means "no more pages" (the
|
||||
/// client's field is a plain String, so never emit null).</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
|
||||
public async Task<ActionResult> ForClub(
|
||||
long clubId,
|
||||
[FromQuery] int? take = null,
|
||||
[FromQuery] string? continuationToken = null)
|
||||
{
|
||||
var pageSize = Math.Clamp(take ?? 50, 1, 100);
|
||||
var skip = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
|
||||
|
||||
// Fetch one extra row to find out whether another page exists.
|
||||
var rows = await ClubEventsAsync(new[] { clubId }, skip, pageSize + 1);
|
||||
var hasMore = rows.Count > pageSize;
|
||||
if (hasMore) rows.RemoveAt(rows.Count - 1);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Events = await ToWireManyAsync(rows),
|
||||
ContinuationToken = hasMore ? (skip + pageSize).ToString() : string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/clubs</c> — events for a specific
|
||||
/// set of clubs. IEGLMOOPLED appends one repeated <c>id</c> query value per
|
||||
/// club (<c>CBKANFIOBCF_NestedType_LKJFABHPIAN.txt:59</c>); ignoring them
|
||||
/// returned every club's events and the watch could not bucket the list.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/clubs")]
|
||||
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var clubIds = new List<long>();
|
||||
foreach (var value in Request.Query["id"]) AddDelimitedIds(clubIds, value);
|
||||
var rows = await ClubEventsAsync(clubIds.Distinct().ToList(), 0, take);
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
public sealed class EventBulkRequest
|
||||
{
|
||||
public List<long>? PlayerEventIds { get; set; }
|
||||
public List<long>? EventIds { get; set; }
|
||||
public List<long>? Ids { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/bulk</c> — verb 2 at
|
||||
/// <c>CBKANFIOBCF.txt:660</c>; the form field is <c>Ids</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_PIKPJPBHOAC.txt:64</c>). GET is kept for the
|
||||
/// 2020 client, which puts the ids in the query string.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("api/playerevents/v1/bulk")]
|
||||
[HttpGet("api/playerevents/v1/bulk")]
|
||||
public async Task<ActionResult> Bulk()
|
||||
{
|
||||
var ids = await ReadEventIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
.ToListAsync();
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/tagfilters</c> — AKCLLEJNFFD reads
|
||||
/// three String lists: <c>PinnedFilters</c>, <c>PopularFilters</c> and
|
||||
/// <c>TrendingFilters</c> (<c>HMDONHHKKGA.txt:279-346</c>). A missing key
|
||||
/// leaves the list null and the chips UI NREs when it enumerates.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/tagfilters")]
|
||||
public IActionResult TagFilters() => Ok(new
|
||||
{
|
||||
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
TrendingFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
|
||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
|
||||
/// events). Response keys (both required, via
|
||||
/// <c>Util.GetObjectListKey</c>): <c>Created</c>, <c>Responses</c>.</summary>
|
||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd events).
|
||||
/// PCELKKHNPHJ is <c>{Created:[PlayerEvent], Responses:[COAGAJPELCG]}</c>
|
||||
/// (<c>ENJDEONHNMO.txt:191-218</c>) where each response entry is
|
||||
/// <c>{PlayerEvent, PlayerEventResponse}</c> (<c>HJKLIIPPPEM.txt:215-258</c>)
|
||||
/// — a flat response row leaves <c>PlayerEvent</c> null and the My Events
|
||||
/// page NREs.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/all")]
|
||||
public async Task<ActionResult> AllPlayerEvents()
|
||||
@@ -63,66 +288,572 @@ public class PlayerEventsController(
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me)
|
||||
return Ok(new { Created = Array.Empty<object>(), Responses = Array.Empty<object>() });
|
||||
return await AllForPlayerAsync(me);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
|
||||
public Task<ActionResult> AllPlayerEventsForAccount(long accountId)
|
||||
=> AllForPlayerAsync(accountId);
|
||||
|
||||
private async Task<ActionResult> AllForPlayerAsync(long playerId)
|
||||
{
|
||||
var created = await db.PlayerEvents
|
||||
.Where(e => e.CreatorPlayerId == me)
|
||||
.Where(e => e.CreatorPlayerId == playerId)
|
||||
.OrderByDescending(e => e.StartsAt)
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.Title,
|
||||
e.Description,
|
||||
e.RoomId,
|
||||
e.StartsAt,
|
||||
e.EndsAt,
|
||||
e.Capacity,
|
||||
})
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
|
||||
var responses = await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == me)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.EventId,
|
||||
r.Response,
|
||||
r.CreatedAt,
|
||||
})
|
||||
.Where(r => r.PlayerId == playerId)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
// Each response entry embeds its own event, which may not be one the
|
||||
// caller created — pull those in too so PlayerEvent is never null.
|
||||
var createdIds = created.Select(e => e.Id).ToHashSet();
|
||||
var missingIds = responses.Select(r => r.EventId).Where(id => !createdIds.Contains(id)).Distinct().ToList();
|
||||
var extraEvents = new List<PlayerEventEntity>();
|
||||
if (missingIds.Count > 0)
|
||||
extraEvents = await db.PlayerEvents.Where(e => missingIds.Contains(e.Id)).ToListAsync();
|
||||
|
||||
var all = created.Concat(extraEvents).ToList();
|
||||
var wire = await ToWireMapAsync(all);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Created = created.Select(e => wire[e.Id]).ToList(),
|
||||
Responses = responses
|
||||
.Where(r => wire.ContainsKey(r.EventId))
|
||||
.Select(r => new
|
||||
{
|
||||
PlayerEvent = wire[r.EventId],
|
||||
PlayerEventResponse = ResponseWire(r),
|
||||
})
|
||||
.ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
||||
/// alternate RSVP route the watch uses for the response-list
|
||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||
/// as <c>v1/{id}/rsvp</c>; behaves identically.</summary>
|
||||
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
|
||||
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
|
||||
=> Rsvp(eventId, body);
|
||||
|
||||
/// <summary>Wire shape verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815</c>:
|
||||
/// PascalCase keys <c>PlayerEventId, Name, Description,
|
||||
/// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId,
|
||||
/// ImageName, Accessibility</c>. Server-side we store these
|
||||
/// under different names (Title, StartsAt, EndsAt, Capacity) —
|
||||
/// remap explicitly here so the watch's
|
||||
/// <c>PlayerEvent.Deserialize</c> picks them up.</summary>
|
||||
private static object ToWire(PlayerEventEntity ev) => new
|
||||
/// <summary>GET <c>api/playerevents/v1/{eventId}/responses</c> — the event
|
||||
/// detail page's attendee list. DFABBIDLDPE dispatches with verb 0
|
||||
/// (<c>CBKANFIOBCF.txt:1699</c>, route literal <c>:1689</c>) and reads
|
||||
/// <c>List<JPIKCIGABBI></c>; the path was registered POST-only, so
|
||||
/// the list 405'd.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/{eventId:long}/responses")]
|
||||
public async Task<ActionResult> ResponsesForEvent(long eventId)
|
||||
{
|
||||
PlayerEventId = ev.Id,
|
||||
Name = ev.Title,
|
||||
ev.Description,
|
||||
StartTime = ev.StartsAt,
|
||||
EndTime = ev.EndsAt,
|
||||
CreatorPlayerId = (int)ev.CreatorPlayerId,
|
||||
AttendeeCount = 0, // computed at query time when needed
|
||||
ev.RoomId,
|
||||
ImageName = string.Empty,
|
||||
Accessibility = 1, // Public
|
||||
var rows = await db.PlayerEventResponses
|
||||
.Where(r => r.EventId == eventId)
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ResponseWire).ToList());
|
||||
}
|
||||
|
||||
// ── Event instance browser ───────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>event/{eventId}/instances</c> — the "which instance of
|
||||
/// this event are my friends in" browser behind the event card's join
|
||||
/// button. Served from the MATCHMAKING-HOST root (no <c>api/</c> prefix),
|
||||
/// which is why it lives on an absolute route here even though the feature
|
||||
/// is a player-event one.
|
||||
///
|
||||
/// Binary evidence: <c>Matchmaking+<GetEventInstanceBrowser>b__0</c>
|
||||
/// (<c>Matchmaking_NestedType_CBMHJMNIHNN.txt:14</c>) formats the literal
|
||||
/// <c>"event/{0}/instances"</c> at <c>:296</c> with <c>[rdi+16]</c> — the
|
||||
/// PlayerEventId off the HPIOAGDJHDH the lambda closes over — then news up
|
||||
/// BNDIAONDFFF with <c>rdx = 0</c> at <c>:124</c>, i.e.
|
||||
/// <c>BestHTTP.HTTPMethods.Get</c>. The declared return type is
|
||||
/// <c>FGLDKEJLAKB<List<PNDCMIMEJLD>></c>, so the body is a BARE
|
||||
/// JSON ARRAY. PNDCMIMEJLD's getters (<c>PNDCMIMEJLD.txt:3-197</c>) are, in
|
||||
/// order, Int64/Int64/Int64/Boolean/DateTime/List<Int32>/String/Int32/
|
||||
/// Boolean/String = RoomInstanceId, RoomId, SubRoomId, IsFull, CreatedAt,
|
||||
/// PlayerIds, SubroomName, PlayerCount, HasModPresent, HashedInstanceId —
|
||||
/// byte-for-byte the SimpleRoomInstance the <c>room/{id}/instances</c>
|
||||
/// browser already returns (<c>Matchmaking.txt:14237</c> issues the room
|
||||
/// variant through the identical BNDIAONDFFF/verb-0 sequence). The last
|
||||
/// four are filled in locally on the watch and are not read back off the
|
||||
/// wire, so we emit the same six camelCase keys
|
||||
/// (<c>Controllers/Match/MatchController.cs:545-553</c>); the 2023 Utf8Json
|
||||
/// formatters carry a camelCase variant per key, so both client
|
||||
/// generations parse it.
|
||||
///
|
||||
/// The event itself only supplies the room to browse: an event is pinned to
|
||||
/// one RoomId, plus the SubRoomId the host picked in the v2 create/edit body
|
||||
/// (persisted in <see cref="EventExtras"/>). Sub-room hops allocate their
|
||||
/// own instance ids and photon rooms, so when the event names a sub-room we
|
||||
/// only surface instances sitting in it.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("/event/{eventId:long}/instances")]
|
||||
public async Task<IActionResult> EventInstances(long eventId)
|
||||
{
|
||||
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
if (evt is null) return NotFound();
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
|
||||
|
||||
// Same sourcing as the room browser: presence rows are the live truth
|
||||
// (one instance per distinct PhotonRoomId), and the caller's OWN private
|
||||
// instances are merged in so the host can still see the instance they
|
||||
// created before anyone has joined it. Another player's private
|
||||
// PhotonRoomId is never listed.
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
var privates = pid == 0
|
||||
? new List<PrivateInstanceEntity>()
|
||||
: await db.PrivateInstances
|
||||
.Where(p => p.RoomId == evt.RoomId && p.OwnerPlayerId == pid)
|
||||
.ToListAsync();
|
||||
|
||||
var byKey = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var (room, playerIds) in presence.EnumerateActiveInstances(evt.RoomId))
|
||||
{
|
||||
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
|
||||
if (subRoomId is long want && room.SubRoomId != want) continue;
|
||||
byKey[room.PhotonRoomId] = new
|
||||
{
|
||||
roomInstanceId = room.RoomInstanceId,
|
||||
roomId = room.RoomId,
|
||||
subRoomId = room.SubRoomId,
|
||||
// MaxCapacity is the per-instance cap the matchmaker handed
|
||||
// out, so this is a real answer rather than a constant false.
|
||||
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
|
||||
// Presence rows are a TTL cache with no creation timestamp —
|
||||
// there is no stored "instance opened at" for a live instance,
|
||||
// so report the observation time. Private-instance rows below
|
||||
// do carry a real CreatedAt and use it.
|
||||
createdAt = DateTime.UtcNow,
|
||||
playerIds = playerIds.Select(p => (int)p).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var p in privates)
|
||||
{
|
||||
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
|
||||
if (subRoomId is long want && p.SubRoomId != want) continue;
|
||||
byKey[p.PhotonRoomId] = new
|
||||
{
|
||||
roomInstanceId = p.Id,
|
||||
roomId = p.RoomId,
|
||||
subRoomId = p.SubRoomId,
|
||||
isFull = false,
|
||||
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
|
||||
playerIds = Array.Empty<int>(),
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(byKey.Values.ToList());
|
||||
}
|
||||
|
||||
// ── Wire shape ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The PlayerEvent fields the 2023 client carries that
|
||||
/// <see cref="PlayerEventEntity"/> has no column for. Adding columns needs
|
||||
/// a migration in <c>Data/Entities</c>, so these live in the PlayerSettings
|
||||
/// side-table: one JSON row per event under
|
||||
/// <c>playerevent:{id}:wire</c>, plus a flat <c>playerevent:{id}:club</c>
|
||||
/// row that keeps club lookups a single indexed query.
|
||||
///
|
||||
/// Public (not private) purely so System.Text.Json's reflection-based
|
||||
/// (de)serialiser can see the type and its accessors.</summary>
|
||||
public sealed class EventExtras
|
||||
{
|
||||
public long? SubRoomId { get; set; }
|
||||
public long? ClubId { get; set; }
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
/// <summary>BAMLHAODDOG ordinal. The enum's member order is not
|
||||
/// recoverable from the ISIL (enums carry no method bodies), so events
|
||||
/// that never had accessibility set keep the historical default of 1
|
||||
/// rather than guessing a new one; once the client PUTs a value we
|
||||
/// echo exactly what it sent.</summary>
|
||||
public int Accessibility { get; set; } = 1;
|
||||
public bool IsMultiInstance { get; set; }
|
||||
public bool SupportMultiInstanceRoomChat { get; set; }
|
||||
public int DefaultBroadcastPermissions { get; set; }
|
||||
public int CanRequestBroadcastPermissions { get; set; }
|
||||
public long? BroadcastingRoomInstanceId { get; set; }
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
private static string ExtrasKey(long eventId) => $"playerevent:{eventId}:wire";
|
||||
private static string ClubKey(long eventId) => $"playerevent:{eventId}:club";
|
||||
|
||||
/// <summary>Wire shape verified against the client's Utf8Json formatter
|
||||
/// <c>HPMMKFGDAEC.txt:1139-1518</c> (and the details variant
|
||||
/// <c>CENEMCMGDKG.txt:1191-1586</c>): PascalCase keys <c>PlayerEventId,
|
||||
/// CreatorPlayerId, RoomId, SubRoomId, ClubId, Name, Description,
|
||||
/// ImageName, StartTime, EndTime, AttendeeCount, Accessibility,
|
||||
/// IsMultiInstance, SupportMultiInstanceRoomChat,
|
||||
/// DefaultBroadcastPermissions, CanRequestBroadcastPermissions,
|
||||
/// BroadcastingRoomInstanceId</c> — property types read off the getters in
|
||||
/// <c>HPIOAGDJHDH.txt</c>. Server-side we store some of these under other
|
||||
/// names (Title, StartsAt, EndsAt), so remap explicitly.</summary>
|
||||
private static Dictionary<string, object?> ToWire(
|
||||
PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new()
|
||||
{
|
||||
["PlayerEventId"] = ev.Id,
|
||||
["CreatorPlayerId"] = (int)ev.CreatorPlayerId,
|
||||
["RoomId"] = ev.RoomId,
|
||||
["SubRoomId"] = extras.SubRoomId,
|
||||
["ClubId"] = extras.ClubId,
|
||||
["Name"] = ev.Title,
|
||||
["Description"] = ev.Description,
|
||||
["ImageName"] = extras.ImageName,
|
||||
["StartTime"] = ev.StartsAt,
|
||||
["EndTime"] = ev.EndsAt,
|
||||
["AttendeeCount"] = attendeeCount,
|
||||
["Accessibility"] = extras.Accessibility,
|
||||
["IsMultiInstance"] = extras.IsMultiInstance,
|
||||
["SupportMultiInstanceRoomChat"] = extras.SupportMultiInstanceRoomChat,
|
||||
["DefaultBroadcastPermissions"] = extras.DefaultBroadcastPermissions,
|
||||
["CanRequestBroadcastPermissions"] = extras.CanRequestBroadcastPermissions,
|
||||
["BroadcastingRoomInstanceId"] = extras.BroadcastingRoomInstanceId,
|
||||
// MDCBEPJCJPO (the includeDetails DTO) is this same object plus
|
||||
// Tags:[{Tag,Type}] (entry keys PCMLHLIBLNJ.txt:191-218). The tagless
|
||||
// formatter skips unknown keys, so emitting Tags unconditionally is
|
||||
// inert there and saves branching on includeDetails. Type is the
|
||||
// KIKIEHKBHNM ordinal — we only ever store free-form tags, so 0.
|
||||
["Tags"] = extras.Tags.Select(t => new { Tag = t, Type = 0 }).ToList(),
|
||||
};
|
||||
|
||||
/// <summary>JPIKCIGABBI — <c>{PlayerEventResponseId, PlayerEventId,
|
||||
/// PlayerId(Int32), CreatedAt, Type}</c>, keys at
|
||||
/// <c>DDDLCGDEPNH.txt:395-502</c>, types off the getters in
|
||||
/// <c>JPIKCIGABBI.txt</c>.</summary>
|
||||
private static object ResponseWire(PlayerEventResponseEntity r) => new
|
||||
{
|
||||
PlayerEventResponseId = r.Id,
|
||||
PlayerEventId = r.EventId,
|
||||
PlayerId = (int)r.PlayerId,
|
||||
r.CreatedAt,
|
||||
Type = r.Response,
|
||||
};
|
||||
|
||||
/// <summary>PHHAKLPGNGC — the wrapper every v2 mutation deserialises.
|
||||
/// </summary>
|
||||
private static object Wrapper(PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new
|
||||
{
|
||||
PlayerEvent = ToWire(ev, extras, attendeeCount),
|
||||
Result = 0, // BNCFHOOCHAI success
|
||||
TagModifyResult = new { Result = 0, Tags = extras.Tags },
|
||||
};
|
||||
|
||||
private async Task<object> WrapperAsync(PlayerEventEntity ev)
|
||||
{
|
||||
var extras = (await LoadExtrasAsync(new[] { ev }))[ev.Id];
|
||||
var counts = await AttendeeCountsAsync(new[] { ev.Id });
|
||||
return Wrapper(ev, extras, counts.GetValueOrDefault(ev.Id));
|
||||
}
|
||||
|
||||
private async Task<List<Dictionary<string, object?>>> ToWireManyAsync(IReadOnlyList<PlayerEventEntity> rows)
|
||||
{
|
||||
if (rows.Count == 0) return [];
|
||||
var extras = await LoadExtrasAsync(rows);
|
||||
var counts = await AttendeeCountsAsync(rows.Select(r => r.Id).ToList());
|
||||
return rows
|
||||
.Select(r => ToWire(r, extras[r.Id], counts.GetValueOrDefault(r.Id)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, Dictionary<string, object?>>> ToWireMapAsync(
|
||||
IReadOnlyList<PlayerEventEntity> rows)
|
||||
{
|
||||
var wire = await ToWireManyAsync(rows);
|
||||
var map = new Dictionary<long, Dictionary<string, object?>>();
|
||||
for (var i = 0; i < rows.Count; i++) map[rows[i].Id] = wire[i];
|
||||
return map;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object?>> ToWireOneAsync(PlayerEventEntity ev)
|
||||
=> (await ToWireManyAsync(new[] { ev }))[0];
|
||||
|
||||
private async Task<Dictionary<long, EventExtras>> LoadExtrasAsync(IReadOnlyCollection<PlayerEventEntity> events)
|
||||
{
|
||||
var map = new Dictionary<long, EventExtras>();
|
||||
foreach (var ev in events) map[ev.Id] = new EventExtras();
|
||||
if (map.Count == 0) return map;
|
||||
|
||||
var keys = map.Keys.Select(ExtrasKey).ToList();
|
||||
var rows = await db.PlayerSettings
|
||||
.AsNoTracking()
|
||||
.Where(s => keys.Contains(s.Key))
|
||||
.Select(s => new { s.Key, s.Value })
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (EventIdFromKey(row.Key) is not long id || !map.ContainsKey(id)) continue;
|
||||
var parsed = DeserializeExtras(row.Value);
|
||||
if (parsed is not null) map[id] = parsed;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static long? EventIdFromKey(string key)
|
||||
{
|
||||
var parts = key.Split(':');
|
||||
return parts.Length >= 3 && long.TryParse(parts[1], out var id) ? id : null;
|
||||
}
|
||||
|
||||
private static EventExtras? DeserializeExtras(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<EventExtras>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveExtrasAsync(PlayerEventEntity evt, EventExtras extras)
|
||||
{
|
||||
// PlayerSettingEntity.Value is MaxLength(1024); bound the tag list so a
|
||||
// long one can never truncate the JSON blob into unparseable garbage.
|
||||
if (extras.Tags.Count > 12) extras.Tags = extras.Tags.Take(12).ToList();
|
||||
extras.Tags = extras.Tags.Select(t => Clamp(t, 32)).ToList();
|
||||
|
||||
await UpsertSettingAsync(evt.CreatorPlayerId, ExtrasKey(evt.Id),
|
||||
JsonSerializer.Serialize(extras));
|
||||
// Flat index row so "events for club X" stays one indexed lookup
|
||||
// instead of a scan through the JSON blobs.
|
||||
await UpsertSettingAsync(evt.CreatorPlayerId, ClubKey(evt.Id),
|
||||
extras.ClubId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty);
|
||||
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task UpsertSettingAsync(long playerId, string key, string value)
|
||||
{
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = key,
|
||||
Value = value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, int>> AttendeeCountsAsync(IReadOnlyCollection<long> eventIds)
|
||||
{
|
||||
if (eventIds.Count == 0) return new Dictionary<long, int>();
|
||||
var ids = eventIds.ToList();
|
||||
var rows = await db.PlayerEventResponses
|
||||
.Where(r => ids.Contains(r.EventId) && r.Response != NotGoingResponse)
|
||||
.GroupBy(r => r.EventId)
|
||||
.Select(g => new { EventId = g.Key, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
return rows.ToDictionary(r => r.EventId, r => r.Count);
|
||||
}
|
||||
|
||||
// ── Query shaping ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>'scheduleFilter' (BJBLPLKMLBE) is an enum ordinal. Only the
|
||||
/// query-key literal survives in the ISIL — enums have no method bodies to
|
||||
/// disassemble — so the member order below is UNVERIFIED. Ordinal 0 (and
|
||||
/// absent) reproduces the previous behaviour exactly, and a wrong guess for
|
||||
/// the others still returns a valid, non-empty event list rather than an
|
||||
/// error.</summary>
|
||||
private static IQueryable<PlayerEventEntity> ApplyScheduleFilter(
|
||||
IQueryable<PlayerEventEntity> rows, int? scheduleFilter)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return scheduleFilter switch
|
||||
{
|
||||
1 => rows.Where(e => e.StartsAt <= now && e.EndsAt > now),
|
||||
2 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(1)),
|
||||
3 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(7)),
|
||||
_ => rows.Where(e => e.EndsAt > now),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>'sort' (PJBLEKMMACM) — same UNVERIFIED-ordinal caveat as
|
||||
/// <see cref="ApplyScheduleFilter"/>; 0/absent keeps soonest-first.</summary>
|
||||
private static IQueryable<PlayerEventEntity> ApplySort(
|
||||
IQueryable<PlayerEventEntity> rows, int? sort) => sort switch
|
||||
{
|
||||
1 => rows.OrderByDescending(e => e.CreatedAt),
|
||||
2 => rows.OrderBy(e => e.EndsAt),
|
||||
_ => rows.OrderBy(e => e.StartsAt),
|
||||
};
|
||||
|
||||
/// <summary>Events attached to any of <paramref name="clubIds"/>. Explicit
|
||||
/// ClubId (persisted since the v2 create/edit body carries it) wins; when
|
||||
/// nothing carries one we fall back to the historical heuristic of "events
|
||||
/// in rooms owned by club members" so pre-existing data still populates the
|
||||
/// tab.</summary>
|
||||
private async Task<List<PlayerEventEntity>> ClubEventsAsync(
|
||||
IReadOnlyCollection<long> clubIds, int skip, int take)
|
||||
{
|
||||
var explicitIds = await EventIdsForClubsAsync(clubIds);
|
||||
IQueryable<PlayerEventEntity> q;
|
||||
if (explicitIds.Count > 0)
|
||||
{
|
||||
q = db.PlayerEvents.Where(e => explicitIds.Contains(e.Id) && e.EndsAt > DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
var roomIds = await ClubRoomIdsAsync(clubIds);
|
||||
if (roomIds.Count == 0) return [];
|
||||
q = db.PlayerEvents.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow);
|
||||
}
|
||||
|
||||
return await q.OrderBy(e => e.StartsAt).Skip(skip).Take(take).ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<List<long>> EventIdsForClubsAsync(IReadOnlyCollection<long> clubIds)
|
||||
{
|
||||
if (clubIds.Count == 0) return [];
|
||||
var wanted = clubIds.Select(c => c.ToString(CultureInfo.InvariantCulture)).ToList();
|
||||
var keys = await db.PlayerSettings
|
||||
.AsNoTracking()
|
||||
.Where(s => wanted.Contains(s.Value)
|
||||
&& s.Key.StartsWith("playerevent:")
|
||||
&& s.Key.EndsWith(":club"))
|
||||
.Select(s => s.Key)
|
||||
.ToListAsync();
|
||||
|
||||
return keys
|
||||
.Select(EventIdFromKey)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<long>> ClubRoomIdsAsync(IReadOnlyCollection<long> clubIds)
|
||||
{
|
||||
var memberships = db.ClubMemberships.AsQueryable();
|
||||
if (clubIds.Count > 0)
|
||||
{
|
||||
var ids = clubIds.ToList();
|
||||
memberships = memberships.Where(m => ids.Contains(m.ClubId));
|
||||
}
|
||||
|
||||
var memberIds = await memberships.Select(m => m.PlayerId).Distinct().ToListAsync();
|
||||
if (memberIds.Count == 0) return [];
|
||||
return await db.Rooms
|
||||
.Where(r => memberIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// ── Create / edit (v2) ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v2</c> — the 2023 create-event flow.
|
||||
/// KKMMBCDGFFN posts BIEFKAOABMP as a RawJsonForm body (verb 2 at
|
||||
/// <c>CBKANFIOBCF.txt:2390</c>) whose 14 keys are
|
||||
/// <c>RoomId, SubRoomId, ClubId, Name, Description, Tags, ImageName,
|
||||
/// StartTime, EndTime, Accessibility, IsMultiInstance,
|
||||
/// SupportMultiInstanceRoomChat, DefaultBroadcastPermissions,
|
||||
/// CanRequestBroadcastPermissions</c> (<c>ACKNGEEHHAE.txt:935-1234</c>),
|
||||
/// and reads the PHHAKLPGNGC wrapper back. Only <c>[HttpGet]</c> was
|
||||
/// registered on this path, so creating an event 405'd.</summary>
|
||||
[HttpPost("api/playerevents/v2")]
|
||||
public async Task<IActionResult> CreateV2()
|
||||
{
|
||||
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim() ?? string.Empty;
|
||||
if (name.Length == 0) return BadRequest("missing_name");
|
||||
|
||||
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt") ?? DateTime.UtcNow;
|
||||
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt") ?? start.AddHours(1);
|
||||
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim() ?? string.Empty;
|
||||
|
||||
var evt = new PlayerEventEntity
|
||||
{
|
||||
CreatorPlayerId = Me,
|
||||
RoomId = await ReadLongFieldAsync("RoomId", "roomId") ?? 0,
|
||||
Title = Clamp(name, 128),
|
||||
Description = Clamp(description, 2000),
|
||||
StartsAt = start,
|
||||
EndsAt = end > start ? end : start.AddHours(1),
|
||||
};
|
||||
db.PlayerEvents.Add(evt);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var extras = new EventExtras();
|
||||
await ApplyBodyToExtrasAsync(extras);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v2/{eventId}</c> — edit-event save.
|
||||
/// IOEJKIPJFOI posts the same BIEFKAOABMP body with verb 2
|
||||
/// (<c>CBKANFIOBCF.txt:2595</c>, route literal <c>:2579</c>); the path was
|
||||
/// GET-only, so editing 405'd.</summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}")]
|
||||
public async Task<IActionResult> EditV2(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
|
||||
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim();
|
||||
if (!string.IsNullOrEmpty(name)) evt.Title = Clamp(name, 128);
|
||||
|
||||
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim();
|
||||
if (description is not null) evt.Description = Clamp(description, 2000);
|
||||
|
||||
var roomId = await ReadLongFieldAsync("RoomId", "roomId");
|
||||
if (roomId is long room && room > 0) evt.RoomId = room;
|
||||
|
||||
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt");
|
||||
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt");
|
||||
if (start is DateTime s) evt.StartsAt = s;
|
||||
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
await ApplyBodyToExtrasAsync(extras);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>Copies whatever subset of the BIEFKAOABMP body is present onto
|
||||
/// <paramref name="extras"/>. Absent keys are left alone so the same helper
|
||||
/// serves both create and edit.</summary>
|
||||
private async Task ApplyBodyToExtrasAsync(EventExtras extras)
|
||||
{
|
||||
var fields = await ReadBodyAsync();
|
||||
|
||||
if (fields.Fields.ContainsKey("SubRoomId"))
|
||||
extras.SubRoomId = await ReadLongFieldAsync("SubRoomId", "subRoomId");
|
||||
if (fields.Fields.ContainsKey("ClubId"))
|
||||
extras.ClubId = await ReadLongFieldAsync("ClubId", "clubId");
|
||||
|
||||
var imageName = await ReadStringFieldAsync("ImageName", "imageName");
|
||||
if (imageName is not null) extras.ImageName = Clamp(imageName.Trim(), 128);
|
||||
|
||||
if (await ReadIntFieldAsync("Accessibility", "accessibility") is int accessibility)
|
||||
extras.Accessibility = accessibility;
|
||||
if (await ReadBoolFieldAsync("IsMultiInstance", "isMultiInstance") is bool multi)
|
||||
extras.IsMultiInstance = multi;
|
||||
if (await ReadBoolFieldAsync("SupportMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
||||
"supportsMultiInstanceRoomChat") is bool chat)
|
||||
extras.SupportMultiInstanceRoomChat = chat;
|
||||
if (await ReadIntFieldAsync("DefaultBroadcastPermissions", "defaultBroadcastPermissions") is int def)
|
||||
extras.DefaultBroadcastPermissions = def;
|
||||
if (await ReadIntFieldAsync("CanRequestBroadcastPermissions", "canRequestBroadcastPermissions") is int req)
|
||||
extras.CanRequestBroadcastPermissions = req;
|
||||
|
||||
var tags = await ReadStringListFieldAsync("Tags", "tags");
|
||||
if (tags is not null) extras.Tags = tags;
|
||||
}
|
||||
|
||||
// ── Create / RSVP (v1, shared with the 2020 client) ──────────────────
|
||||
|
||||
public sealed record CreateEventRequest(
|
||||
string Title, string? Description, long RoomId,
|
||||
DateTime StartsAt, DateTime EndsAt, int Capacity);
|
||||
@@ -145,11 +876,7 @@ public class PlayerEventsController(
|
||||
};
|
||||
db.PlayerEvents.Add(ev);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
ev.Id, ev.Title, ev.Description, ev.RoomId,
|
||||
ev.StartsAt, ev.EndsAt, ev.Capacity,
|
||||
});
|
||||
return Ok(await ToWireOneAsync(ev));
|
||||
}
|
||||
|
||||
public sealed record RsvpRequest(int Response);
|
||||
@@ -178,25 +905,45 @@ public class PlayerEventsController(
|
||||
await notifications.NotifyAsync(ev.CreatorPlayerId,
|
||||
PushNotificationId.PlayerEventResponseChanged,
|
||||
new { ev.Id, From = Me, body.Response });
|
||||
// Left as the historical shape on purpose: this route is 2020-only
|
||||
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
|
||||
// so there is no 2023 DTO to match here.
|
||||
return Ok(new { row.EventId, row.PlayerId, row.Response });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
|
||||
/// caller's RSVP state for an event. <c>Response</c> is the
|
||||
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
|
||||
/// Idempotent — re-responding overwrites.
|
||||
///
|
||||
/// 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>
|
||||
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
||||
/// alternate RSVP route the 2020 watch uses for the response-list
|
||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||
/// as <c>v1/{id}/rsvp</c>; behaves identically. The 2023 client GETs this
|
||||
/// path instead — see <see cref="ResponsesForEvent"/>.</summary>
|
||||
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
|
||||
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
|
||||
=> Rsvp(eventId, body);
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the caller's RSVP
|
||||
/// state. Two client generations share this route with different
|
||||
/// contracts:
|
||||
/// * 2020.12 posts a form and reads a BARE int (DGOPHENCPOC); returning
|
||||
/// an object throws InvalidCastException there — see
|
||||
/// <c>recroom-2020-client-response-contracts.md</c>.
|
||||
/// * 2023-03 posts a RawJsonForm body <c>{PlayerEventId, Type}</c>
|
||||
/// (NKIFBKJALEJ, keys at <c>GFHNJMMBHFD.txt:203-238</c>; body build at
|
||||
/// <c>CBKANFIOBCF_NestedType_NFCEOKLHDAG.txt:81-112</c>) and reads
|
||||
/// CEKABGOIOAF, an OBJECT <c>{Result:int}</c>
|
||||
/// (<c>GJJGDENPOAP.txt:139-150</c>).
|
||||
/// Branch the response shape on the body encoding the caller actually
|
||||
/// used — that is the only signal that distinguishes them.</summary>
|
||||
[HttpPost("api/playerevents/v1/respond")]
|
||||
public async Task<IActionResult> RespondForm(
|
||||
[FromForm(Name = "EventId")] long? eventId,
|
||||
[FromForm(Name = "Response")] int response = 0)
|
||||
public async Task<IActionResult> RespondForm()
|
||||
{
|
||||
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/);
|
||||
var legacyForm = Request.HasFormContentType;
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long evt || evt <= 0) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
var response = await ReadIntFieldAsync("Type", "type", "Response", "response") ?? 0;
|
||||
if (!await db.PlayerEvents.AnyAsync(e => e.Id == evt))
|
||||
return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
var existing = await db.PlayerEventResponses
|
||||
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
|
||||
if (existing is null)
|
||||
@@ -204,33 +951,237 @@ public class PlayerEventsController(
|
||||
else
|
||||
existing.Response = response;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(0 /*Success*/);
|
||||
return RespondResult(legacyForm, 0 /*Success*/);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
|
||||
/// the caller's RSVP row. Same bare-int wire shape as
|
||||
/// <see cref="RespondForm"/>.</summary>
|
||||
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop the
|
||||
/// caller's RSVP row. Same two-generation split as
|
||||
/// <see cref="RespondForm"/>. The 2023 body is
|
||||
/// <c>JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest)</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_MDJMHFHPOBO.txt:65-78</c>) whose single field
|
||||
/// is name-preserved: <c>public long PlayerEventId</c>
|
||||
/// (2023.06.21 <c>dump.cs:1291301</c>).</summary>
|
||||
[HttpPost("api/playerevents/v1/deleteResponse")]
|
||||
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
|
||||
public async Task<IActionResult> DeleteResponseForm()
|
||||
{
|
||||
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/);
|
||||
var legacyForm = Request.HasFormContentType;
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == Me && r.EventId == evt)
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok(0 /*Success*/);
|
||||
return RespondResult(legacyForm, 0 /*Success*/);
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
|
||||
/// delete an event the caller created.</summary>
|
||||
private IActionResult RespondResult(bool legacyForm, int code)
|
||||
=> legacyForm ? Ok(code) : Ok(new { Result = code });
|
||||
|
||||
/// <summary>DELETE/POST <c>api/playerevents/v2/delete/{id}</c> — hard
|
||||
/// delete an event the caller created. GJMCEDALKJO dispatches with verb 2
|
||||
/// (<c>CBKANFIOBCF.txt:4166</c>, route literal <c>:4156</c>) and reads the
|
||||
/// PHHAKLPGNGC wrapper, so POST plus the wrapper body are both required;
|
||||
/// DELETE stays for the 2020 client.</summary>
|
||||
[HttpPost("api/playerevents/v2/delete/{id:long}")]
|
||||
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
|
||||
public async Task<IActionResult> DeleteByPath(long id)
|
||||
{
|
||||
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me);
|
||||
if (evt is null) return NotFound();
|
||||
|
||||
// Snapshot the wire shape before the rows go away — the wrapper still
|
||||
// has to echo the event that was deleted.
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
|
||||
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
|
||||
|
||||
db.PlayerEvents.Remove(evt);
|
||||
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
|
||||
.ExecuteDeleteAsync();
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
return Ok(Wrapper(evt, extras, attendees));
|
||||
}
|
||||
|
||||
// ── Field mutations (v2) ─────────────────────────────────────────────
|
||||
// Every one of these is a PUT (verb 3, e.g. CBKANFIOBCF.txt:2744) whose
|
||||
// fields arrive as x-www-form-urlencoded, and every one returns the
|
||||
// PHHAKLPGNGC wrapper — a flat event body leaves the client's PlayerEvent
|
||||
// null right after a successful save. POST is kept alongside for the 2020
|
||||
// client.
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
|
||||
public async Task<IActionResult> UpdateName(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("name", "Name", "value");
|
||||
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
|
||||
evt.Title = Clamp(value.Trim(), 128);
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/description")]
|
||||
public async Task<IActionResult> UpdateDescription(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
|
||||
evt.Description = Clamp(value.Trim(), 2000);
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/room</c> — FPPNHCMPAKK sends
|
||||
/// <c>roomId</c> AND an optional <c>subRoomId</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_AKPPDNLCACI.txt:88,101</c>); dropping the
|
||||
/// latter meant the event never pointed at the sub-room the host picked.
|
||||
/// </summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
|
||||
public async Task<IActionResult> UpdateRoom(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var roomId = await ReadLongFieldAsync("roomId", "RoomId", "value");
|
||||
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
|
||||
evt.RoomId = id;
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/time")]
|
||||
public async Task<IActionResult> UpdateTime(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var start = await ReadDateFieldAsync("startTime", "StartTime", "startsAt", "StartsAt");
|
||||
var end = await ReadDateFieldAsync("endTime", "EndTime", "endsAt", "EndsAt");
|
||||
if (start is DateTime s) evt.StartsAt = s;
|
||||
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
|
||||
if (start is null && end is null) return BadRequest("missing_time");
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/image")]
|
||||
public async Task<IActionResult> UpdateImage(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.ImageName = Clamp(value.Trim(), 128);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/accessibility")]
|
||||
public async Task<IActionResult> UpdateAccessibility(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
|
||||
if (value is not int accessibility) return BadRequest("missing_accessibility");
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.Accessibility = accessibility;
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/tags</c>. JHLMDMGELFD does NOT
|
||||
/// send form fields — it serialises <c>List<String></c> straight into
|
||||
/// a RawJsonForm body, i.e. a BARE JSON ARRAY
|
||||
/// (<c>CBKANFIOBCF_NestedType_PNFGKCKCGEL.txt:63-81</c>). The old field
|
||||
/// reader only looked inside JSON objects, so every save stored an empty
|
||||
/// string and silently cleared the tags. The wrapper's
|
||||
/// <c>TagModifyResult.Tags</c> is what the client re-reads afterwards.
|
||||
/// </summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
|
||||
public async Task<IActionResult> UpdateTags(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var tags = await ReadStringListFieldAsync("tags", "Tags", "value") ?? new List<string>();
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.Tags = tags;
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/multiinstance</c>. IEGDKKAKHBD
|
||||
/// sends FOUR form fields — <c>isMultiInstance</c>,
|
||||
/// <c>supportsMultiInstanceRoomChat</c>, <c>defaultBroadcastPermissions</c>,
|
||||
/// <c>canRequestBroadcastPermissions</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:122-161</c>). The handler used
|
||||
/// to look for a field named <c>multiInstance</c>, which the client never
|
||||
/// sends, so the toggle always stored false and the other three were
|
||||
/// dropped. Note the request spells it "supports…" while the response key
|
||||
/// is "Support…" — both spellings are accepted here.</summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
|
||||
public async Task<IActionResult> UpdateMultiInstance(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
|
||||
extras.IsMultiInstance =
|
||||
await ReadBoolFieldAsync("isMultiInstance", "IsMultiInstance", "multiInstance", "value") ?? false;
|
||||
extras.SupportMultiInstanceRoomChat =
|
||||
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
||||
"SupportMultiInstanceRoomChat") ?? false;
|
||||
extras.DefaultBroadcastPermissions =
|
||||
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0;
|
||||
extras.CanRequestBroadcastPermissions =
|
||||
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0;
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/club</c>. JLHJHGANFDM takes a
|
||||
/// <c>Nullable<Int64></c> and boxes it into the single form field
|
||||
/// <c>clubId</c> (<c>CBKANFIOBCF_NestedType_DJLDOCJBFPO.txt:60-71</c>) — a
|
||||
/// null therefore arrives as an absent/empty field, which is the client's
|
||||
/// only way to DETACH the club. Rejecting that with 400 made detaching
|
||||
/// impossible.</summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
|
||||
public async Task<IActionResult> UpdateClub(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
|
||||
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
|
||||
if (clubId is long id && id > 0)
|
||||
{
|
||||
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
|
||||
return Forbid();
|
||||
extras.ClubId = id;
|
||||
}
|
||||
else
|
||||
{
|
||||
extras.ClubId = null;
|
||||
}
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpDelete("api/playerevents/v1/{eventId:long}")]
|
||||
@@ -245,10 +1196,13 @@ public class PlayerEventsController(
|
||||
var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId);
|
||||
db.PlayerEventResponses.RemoveRange(responses);
|
||||
await db.SaveChangesAsync();
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── Bulk-invite (Phase 8) ────────────────────────────────────────────
|
||||
// ── Bulk-invite ──────────────────────────────────────────────────────
|
||||
|
||||
public sealed class BulkInviteRequest
|
||||
{
|
||||
@@ -256,12 +1210,17 @@ public class PlayerEventsController(
|
||||
public List<int>? InvitedPlayerIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c> — wire
|
||||
/// type per agent ISIL extraction: <c>BulkInviteRequest{PlayerEventId,
|
||||
/// InvitedPlayerIds:List<int>}</c>; response
|
||||
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
|
||||
/// We insert one MessageEntity per recipient so the watch's inbox
|
||||
/// shows the invite, and push a per-recipient notification.</summary>
|
||||
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c>. Request is
|
||||
/// <c>JsonUtility.ToJson(RecNet.Events.BulkInviteRequest)</c> —
|
||||
/// name-preserved fields <c>PlayerEventId</c> / <c>InvitedPlayerIds</c>
|
||||
/// (2023.06.21 <c>dump.cs:1288323-1288324</c>, body build at
|
||||
/// <c>CBKANFIOBCF_NestedType_MIKAHKKCIEE.txt:69-96</c>). Response is
|
||||
/// IDELKAJIOLI <c>{FailedInvites, Result}</c>
|
||||
/// (<c>GAODOHODBDE.txt:203-238</c>) where each failed entry is
|
||||
/// <c>{InvitedPlayerId, Result}</c> (<c>MOJLGDKFECO.txt:203-238</c>) — the
|
||||
/// entries used to be <c>{PlayerId, Error}</c> and deserialised as all
|
||||
/// defaults. Error paths return the same JSON shape rather than a
|
||||
/// NotFound()/Forbid() body the client cannot parse.</summary>
|
||||
[HttpPost("api/playerevents/v1/bulkInvite")]
|
||||
public async Task<IActionResult> BulkInvite([FromBody] BulkInviteRequest req)
|
||||
{
|
||||
@@ -269,8 +1228,11 @@ public class PlayerEventsController(
|
||||
return Ok(new { FailedInvites = Array.Empty<object>(), Result = 0 });
|
||||
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
|
||||
if (ev is null) return NotFound();
|
||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||
// BNCFHOOCHAI's member order isn't recoverable (enums have no method
|
||||
// bodies to disassemble); any non-zero code reads as "failed" on the
|
||||
// client, which is all the invite sheet branches on.
|
||||
if (ev is null) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
|
||||
if (ev.CreatorPlayerId != Me) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
|
||||
|
||||
var failed = new List<object>();
|
||||
var sender = Me;
|
||||
@@ -280,7 +1242,7 @@ public class PlayerEventsController(
|
||||
var exists = await db.Players.AnyAsync(p => p.Id == rid);
|
||||
if (!exists)
|
||||
{
|
||||
failed.Add(new { PlayerId = rid, Error = "unknown player" });
|
||||
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
|
||||
continue;
|
||||
}
|
||||
db.Messages.Add(new MessageEntity
|
||||
@@ -297,7 +1259,47 @@ public class PlayerEventsController(
|
||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||
}
|
||||
|
||||
// ── Report (Phase 8) ─────────────────────────────────────────────────
|
||||
/// <summary>POST <c>api/playerevents/v1/broadcast</c> — set or clear the
|
||||
/// room instance an event is being broadcast from. JBHPMONELAO posts
|
||||
/// <c>RecNet.Events.BroadcastRoomInstanceRequest</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_EHHMGKFGGCC.txt:81</c>) whose JSON keys are
|
||||
/// <c>PlayerEventId</c> and the nullable <c>BroadcastRoomInstanceId</c>
|
||||
/// (<c>EOFEKNIHNNC.txt:215-258</c>), and reads the PHHAKLPGNGC wrapper.
|
||||
/// This used to be implemented as a text-message blast to every RSVP,
|
||||
/// which both spammed attendees and returned an unparseable body.</summary>
|
||||
[HttpPost("api/playerevents/v1/broadcast")]
|
||||
public async Task<IActionResult> Broadcast()
|
||||
{
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long id) return BadRequest("missing_event");
|
||||
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id);
|
||||
if (evt is null) return NotFound();
|
||||
if (evt.CreatorPlayerId != Me) return Forbid();
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.BroadcastingRoomInstanceId = await ReadLongFieldAsync(
|
||||
"BroadcastRoomInstanceId", "broadcastRoomInstanceId",
|
||||
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
|
||||
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
|
||||
// them to re-fetch instead of sending a chat message.
|
||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
|
||||
{
|
||||
var recipients = await db.PlayerEventResponses
|
||||
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
|
||||
.Select(r => r.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
|
||||
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
|
||||
}
|
||||
|
||||
// ── Report ───────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class PlayerEventReportRequest
|
||||
{
|
||||
@@ -327,4 +1329,213 @@ public class PlayerEventsController(
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
// ── Request parsing ──────────────────────────────────────────────────
|
||||
|
||||
private static string Clamp(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max];
|
||||
|
||||
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
|
||||
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
|
||||
|
||||
private async Task<List<long>> ReadEventIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
AddDelimitedIds(ids, value);
|
||||
|
||||
var body = await ReadBodyAsync();
|
||||
foreach (var key in new[] { "playerEventIds", "eventIds", "ids" })
|
||||
{
|
||||
if (body.Repeated.TryGetValue(key, out var values))
|
||||
foreach (var value in values) AddDelimitedIds(ids, value);
|
||||
}
|
||||
if (body.RootArray is not null)
|
||||
foreach (var value in body.RootArray) AddDelimitedIds(ids, value);
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private static void AddDelimitedIds(List<long> ids, string? value)
|
||||
{
|
||||
foreach (var part in (value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ReadStringFieldAsync(params string[] names)
|
||||
{
|
||||
var body = await ReadBodyAsync();
|
||||
foreach (var name in names)
|
||||
if (body.Fields.TryGetValue(name, out var value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Reads a list-valued field. Handles all three encodings the
|
||||
/// client uses: a bare JSON array body (the tags PUT), a JSON array nested
|
||||
/// under a key (the v2 create/edit body's <c>Tags</c>), and repeated /
|
||||
/// comma-joined form values.</summary>
|
||||
private async Task<List<string>?> ReadStringListFieldAsync(params string[] names)
|
||||
{
|
||||
var body = await ReadBodyAsync();
|
||||
if (body.RootArray is not null) return body.RootArray;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (body.Repeated.TryGetValue(name, out var repeated) && repeated.Count > 1)
|
||||
return repeated.Select(v => v.Trim()).Where(v => v.Length > 0).ToList();
|
||||
|
||||
if (!body.Fields.TryGetValue(name, out var raw)) continue;
|
||||
raw = raw.Trim();
|
||||
if (raw.Length == 0) return new List<string>();
|
||||
if (raw.StartsWith('['))
|
||||
{
|
||||
var parsed = ParseJsonStringArray(raw);
|
||||
if (parsed is not null) return parsed;
|
||||
}
|
||||
return raw
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string>? ParseJsonStringArray(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
|
||||
return ElementToStrings(doc.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ElementToStrings(JsonElement array)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var item in array.EnumerateArray())
|
||||
{
|
||||
var value = item.ValueKind == JsonValueKind.String ? item.GetString() : item.GetRawText();
|
||||
if (!string.IsNullOrWhiteSpace(value)) list.Add(value.Trim());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private async Task<long?> ReadLongFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
if (bool.TryParse(value, out var parsed)) return parsed;
|
||||
// Form-encoded booleans arrive as "True"/"False" from the client's
|
||||
// boxed-bool ToString, but a JSON body may carry 1/0.
|
||||
return value switch { "1" => true, "0" => false, _ => null };
|
||||
}
|
||||
|
||||
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
// The client always sends UTC (Utf8Json ISO-8601 with a Z, or
|
||||
// DateTime.ToString() on an already-UTC value). Force the parse to land
|
||||
// on Kind=Utc so it compares correctly against DateTime.UtcNow.
|
||||
return DateTime.TryParse(value, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Everything the request carries, read once and cached — the body
|
||||
/// stream can only be consumed a single time.</summary>
|
||||
private sealed class ParsedBody
|
||||
{
|
||||
public Dictionary<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, List<string>> Repeated { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Set when the whole body is a bare JSON array — the shape
|
||||
/// the tags PUT sends.</summary>
|
||||
public List<string>? RootArray { get; set; }
|
||||
}
|
||||
|
||||
private const string BodyItemKey = "__playerevent_body";
|
||||
|
||||
private async Task<ParsedBody> ReadBodyAsync()
|
||||
{
|
||||
if (HttpContext.Items.TryGetValue(BodyItemKey, out var cached) && cached is ParsedBody existing)
|
||||
return existing;
|
||||
|
||||
var body = new ParsedBody();
|
||||
foreach (var pair in Request.Query)
|
||||
{
|
||||
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
{
|
||||
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
|
||||
}
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0)
|
||||
{
|
||||
// Read the raw text rather than gating on Content-Type: the client's
|
||||
// RawJsonForm does set application/json, but the JSON path is also
|
||||
// the fallback for anything else that isn't a form.
|
||||
using var reader = new StreamReader(
|
||||
Request.Body, System.Text.Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||
var raw = await reader.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
switch (doc.RootElement.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
body.Fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? string.Empty
|
||||
: prop.Value.GetRawText();
|
||||
if (prop.Value.ValueKind == JsonValueKind.Array)
|
||||
body.Repeated[prop.Name] = ElementToStrings(prop.Value);
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
body.RootArray = ElementToStrings(doc.RootElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Empty or non-JSON body → query/form values only.
|
||||
}
|
||||
}
|
||||
|
||||
HttpContext.Items[BodyItemKey] = body;
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
|
||||
[AllowAnonymous]
|
||||
@@ -202,7 +202,7 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new { success = true });
|
||||
if (pid is not long me) return HileResult();
|
||||
var detail = (rawDetail ?? string.Empty).Trim();
|
||||
if (type is int hileType)
|
||||
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
|
||||
@@ -215,7 +215,22 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
GameSessionId = gameSessionId ?? 0,
|
||||
Category = "hile",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true });
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
logger.LogWarning(ex, "[hile] dropping low-priority hile heartbeat because sqlite is busy");
|
||||
}
|
||||
return HileResult();
|
||||
}
|
||||
|
||||
private static IActionResult HileResult() => new ContentResult
|
||||
{
|
||||
Content = "true",
|
||||
ContentType = "application/json",
|
||||
StatusCode = StatusCodes.Status200OK,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,13 +29,14 @@ namespace DorkNet.Server.Controllers.API.PlayerState;
|
||||
[ApiController]
|
||||
public class PlayerStateController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
LevelService level) : ControllerBase
|
||||
{
|
||||
// RecNet.Progressions.GetProgressionById / GetMyProgression — real
|
||||
// values from PlayerEntity.Level / .XP. Default 1/0 when the
|
||||
// account hasn't been seen yet (synthetic ids passed in queries).
|
||||
[HttpGet("api/players/v1/progression/{accountId:long}")]
|
||||
[HttpGet("api/players/v2/progression/{accountId:long}")]
|
||||
public async Task<ActionResult<Progression>> GetProgression(long accountId)
|
||||
{
|
||||
var p = await db.Players
|
||||
@@ -51,12 +52,25 @@ public class PlayerStateController(
|
||||
}
|
||||
|
||||
[HttpPost("api/players/v1/progression/bulk")]
|
||||
[HttpPost("api/players/v2/progression/bulk")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<List<Progression>>> GetProgressionBulk(
|
||||
[FromForm(Name = "Ids")] string? ids)
|
||||
{
|
||||
var idList = ParseIds(ids).Select(i => (long)i).ToList();
|
||||
if (idList.Count == 0) return Ok(new List<Progression>());
|
||||
return Ok(await BuildProgressionBulkAsync(idList));
|
||||
}
|
||||
|
||||
[HttpGet("api/players/v2/progression/bulk")]
|
||||
public async Task<ActionResult<List<Progression>>> GetProgressionBulkV2Get()
|
||||
{
|
||||
var idList = ParseQueryIds().ToList();
|
||||
return Ok(await BuildProgressionBulkAsync(idList));
|
||||
}
|
||||
|
||||
private async Task<List<Progression>> BuildProgressionBulkAsync(IReadOnlyCollection<long> idList)
|
||||
{
|
||||
if (idList.Count == 0) return new List<Progression>();
|
||||
var rows = await db.Players
|
||||
.Where(p => idList.Contains(p.Id))
|
||||
.Select(p => new Progression
|
||||
@@ -72,26 +86,40 @@ public class PlayerStateController(
|
||||
rows.AddRange(idList
|
||||
.Where(id => !found.Contains((int)id))
|
||||
.Select(id => new Progression { PlayerId = (int)id }));
|
||||
return Ok(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// RecNet.Reputations.GetReputationById — aggregates CheerEntity
|
||||
// by category. Each Cheer* count is the sum of distinct cheers
|
||||
// received from other players in that category.
|
||||
[HttpGet("api/playerReputation/v1/{accountId:long}")]
|
||||
[HttpGet("api/playerReputation/v2/{accountId:long}")]
|
||||
public async Task<ActionResult<Reputation>> GetReputation(long accountId) =>
|
||||
Ok(await BuildReputationAsync(accountId));
|
||||
|
||||
[HttpPost("api/playerReputation/v1/bulk")]
|
||||
[HttpPost("api/playerReputation/v2/bulk")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<List<Reputation>>> GetReputationBulk(
|
||||
[FromForm(Name = "Ids")] string? ids)
|
||||
{
|
||||
var idList = ParseIds(ids).Select(i => (long)i).ToList();
|
||||
return Ok(await BuildReputationBulkAsync(idList));
|
||||
}
|
||||
|
||||
[HttpGet("api/playerReputation/v2/bulk")]
|
||||
public async Task<ActionResult<List<Reputation>>> GetReputationBulkV2Get()
|
||||
{
|
||||
var idList = ParseQueryIds().ToList();
|
||||
return Ok(await BuildReputationBulkAsync(idList));
|
||||
}
|
||||
|
||||
private async Task<List<Reputation>> BuildReputationBulkAsync(IReadOnlyCollection<long> idList)
|
||||
{
|
||||
var result = new List<Reputation>(idList.Count);
|
||||
foreach (var id in idList)
|
||||
result.Add(await BuildReputationAsync(id));
|
||||
return Ok(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>POST api/playerReputation/v1/cheer/{playerId} — cheer
|
||||
@@ -122,9 +150,10 @@ public class PlayerStateController(
|
||||
// Reward the cheered player with a small XP bump.
|
||||
await level.AwardXpAsync(playerId, LevelService.CheerReceivedXp, $"cheer_from:{me}");
|
||||
|
||||
await notifications.NotifyAsync(playerId,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "CheerReceived", From = me, Type = type });
|
||||
// Real PlayerCheer notification (persisted + pushed as a Message),
|
||||
// replacing the old blank-account SubscriptionUpdateProfile misuse.
|
||||
await systemNotifications.SendAsync(playerId,
|
||||
SystemNotificationService.MessageType.PlayerCheer, fromPlayerId: me);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -216,6 +245,23 @@ public class PlayerStateController(
|
||||
.Select(s => int.TryParse(s, out var v) ? v : 0)
|
||||
.Where(v => v != 0);
|
||||
|
||||
private IEnumerable<long> ParseQueryIds()
|
||||
{
|
||||
foreach (var (key, values) in Request.Query)
|
||||
{
|
||||
if (long.TryParse(key, out var keyId) && keyId > 0)
|
||||
yield return keyId;
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (long.TryParse(value, out var valueId) && valueId > 0)
|
||||
yield return valueId;
|
||||
foreach (var part in value.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var partId) && partId > 0)
|
||||
yield return partId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecNet.PlayerReporting.GetModerationBlockDetails
|
||||
// IMPORTANT: returns a single object, not an array. Defaults indicate
|
||||
// "user is not currently blocked / kicked".
|
||||
|
||||
+50
-7
@@ -20,8 +20,7 @@ namespace DorkNet.Server.Controllers.API.PlayerSubscriptions;
|
||||
[Route("api/[controller]/v1")]
|
||||
[Authorize]
|
||||
public class PlayerSubscriptionsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications) : ControllerBase
|
||||
DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
@@ -45,6 +44,51 @@ public class PlayerSubscriptionsController(
|
||||
return Ok(subs);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/subscription/top/creators/today</c> — the
|
||||
/// subscriptions tab's "top creators" carousel. Client contract
|
||||
/// (RecNet.Runtime <c>JDBPOMCAIIG()</c>) is a bare
|
||||
/// <c>List<creator></c> of <c>{ accountId:int, subscriberCount:long,
|
||||
/// … }</c>. Ranks creators by total subscriber count. The leading "/"
|
||||
/// makes this route absolute, bypassing the class's
|
||||
/// <c>api/[controller]/v1</c> prefix.</summary>
|
||||
[HttpGet("/subscription/top/creators/today")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> TopCreatorsToday([FromQuery] int? take)
|
||||
{
|
||||
var limit = Math.Clamp(take ?? 20, 1, 100);
|
||||
var top = await db.Subscriptions
|
||||
.GroupBy(s => s.TargetPlayerId)
|
||||
.Select(g => new { AccountId = g.Key, SubscriberCount = (long)g.Count() })
|
||||
.OrderByDescending(x => x.SubscriberCount)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
|
||||
// A day-window boundary for "today"'s new-subscriber tallies.
|
||||
var since = DateTime.UtcNow.Date;
|
||||
var todayByTarget = await db.Subscriptions
|
||||
.Where(s => s.CreatedAt >= since)
|
||||
.GroupBy(s => s.TargetPlayerId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
// Dictionaries preserve literal key casing (anonymous objects would be
|
||||
// forced to camelCase by the server policy); the creator DTO's keys
|
||||
// are obfuscated in the dump, so emit both cases to be safe.
|
||||
return Ok(top.Select(c =>
|
||||
{
|
||||
var newToday = todayByTarget.GetValueOrDefault(c.AccountId);
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["AccountId"] = (int)c.AccountId,
|
||||
["accountId"] = (int)c.AccountId,
|
||||
["SubscriberCount"] = c.SubscriberCount,
|
||||
["subscriberCount"] = c.SubscriberCount,
|
||||
["NewSubscribersToday"] = newToday,
|
||||
["newSubscribersToday"] = newToday,
|
||||
};
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("subscribe/{targetId:long}")]
|
||||
public async Task<ActionResult> Subscribe(long targetId)
|
||||
{
|
||||
@@ -61,11 +105,10 @@ public class PlayerSubscriptionsController(
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Tell the target their subscriber count changed so the watch
|
||||
// refreshes their profile page if open.
|
||||
await notifications.NotifyAsync(targetId,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "NewSubscriber", From = Me });
|
||||
// No real-time push: no client message type for new subscribers, and
|
||||
// the old SubscriptionUpdateProfile {Reason:"NewSubscriber"} blob
|
||||
// decoded to a blank account ("Orphan player account (0)") without
|
||||
// refreshing anything. The subscriber count refreshes on next fetch.
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayerWarnings;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/playerwarnings")]
|
||||
public async Task<IActionResult> Warnings()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == me && s.Key.StartsWith("warning:"))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
return Ok(rows
|
||||
.Select(ToWarningWire)
|
||||
.Where(w => !w.Acknowledged)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <summary>Moderator issues a warning against a player. The 2023-03-21
|
||||
/// client POSTs this (verb register <c>rdx=2</c> at
|
||||
/// RecNet.Runtime/FPIBGPIAOBI.txt:3455) with the form keys
|
||||
/// <c>WarnedPlayerId</c>, <c>ReportCategory</c>, <c>DisplayReason</c> and
|
||||
/// <c>ModeratorNote</c> (:3468-3520), matching the issuing method
|
||||
/// <c>FGLDKEJLAKB<DJMHAFPGLLN> OEJIMKKDMBI(Int32, BDOGOIGCKMK,
|
||||
/// String, String)</c> (:3102).
|
||||
///
|
||||
/// Only GET was registered here, so every warning the moderator tools
|
||||
/// tried to create came back 405 and silently failed.</summary>
|
||||
[HttpPost("api/playerwarnings")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> CreateWarning(
|
||||
[FromForm(Name = "WarnedPlayerId")] long? warnedPlayerId,
|
||||
[FromForm(Name = "ReportCategory")] int? reportCategory,
|
||||
[FromForm(Name = "DisplayReason")] string? displayReason,
|
||||
[FromForm(Name = "ModeratorNote")] string? moderatorNote)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
|
||||
var moderator = await db.Players
|
||||
.Where(p => p.Id == me)
|
||||
.Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam))
|
||||
.FirstOrDefaultAsync();
|
||||
if (moderator != true)
|
||||
return Ok(new { Success = false, Message = "not_authorized" });
|
||||
|
||||
var target = warnedPlayerId
|
||||
?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0);
|
||||
if (target <= 0) return Ok(new { Success = false, Message = "missing_player" });
|
||||
if (!await db.Players.AnyAsync(p => p.Id == target))
|
||||
return Ok(new { Success = false, Message = "player_not_found" });
|
||||
|
||||
// Same "warning:{id}|message|category|createdAt|acknowledged" layout the
|
||||
// GET list handler parses in ToWarningWire.
|
||||
var reason = (displayReason ?? string.Empty).Trim();
|
||||
var note = (moderatorNote ?? string.Empty).Trim();
|
||||
var message = string.IsNullOrEmpty(note) ? reason : $"{reason} ({note})";
|
||||
var id = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff");
|
||||
|
||||
db.PlayerSettings.Add(new DorkNet.Server.Data.Entities.PlayerSettingEntity
|
||||
{
|
||||
PlayerId = target,
|
||||
Key = $"warning:{id}",
|
||||
Value = string.Join('|',
|
||||
message.Replace('|', '/'),
|
||||
(reportCategory ?? 0).ToString(),
|
||||
DateTime.UtcNow.ToString("O"),
|
||||
"false"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty, WarningId = id });
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client's acknowledge call takes NO arguments —
|
||||
/// the issuing method is <c>FGLDKEJLAKB<DJMHAFPGLLN> DOFADJPFBMK()</c>
|
||||
/// (FPIBGPIAOBI.txt:3524) and it POSTs an empty body (:3590-3592). It means
|
||||
/// "acknowledge my pending warning".
|
||||
///
|
||||
/// Demanding a warningId made every call 400, so the modal was never marked
|
||||
/// read and re-appeared on each login. With no id supplied we now
|
||||
/// acknowledge the newest unacknowledged warning, and acknowledging nothing
|
||||
/// is a success rather than an error.</summary>
|
||||
[HttpPost("api/playerwarnings/acknowledge")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Acknowledge([FromForm] string? warningId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var id = warningId ?? Request.Query["warningId"].FirstOrDefault() ?? Request.Query["id"].FirstOrDefault();
|
||||
|
||||
DorkNet.Server.Data.Entities.PlayerSettingEntity? row;
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
// Acknowledged state lives inside the packed Value string, so the
|
||||
// filter has to run client-side after materialising.
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == me && s.Key.StartsWith("warning:"))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged);
|
||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
else
|
||||
{
|
||||
row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
|
||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
var parts = row.Value.Split('|');
|
||||
row.Value = string.Join('|', parts.Take(3).Append("true"));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
|
||||
}
|
||||
|
||||
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
|
||||
{
|
||||
var parts = row.Value.Split('|');
|
||||
return new WarningWire
|
||||
{
|
||||
WarningId = row.Key["warning:".Length..],
|
||||
Message = parts.ElementAtOrDefault(0) ?? string.Empty,
|
||||
Category = int.TryParse(parts.ElementAtOrDefault(1), out var category) ? category : 0,
|
||||
CreatedAt = parts.ElementAtOrDefault(2) ?? string.Empty,
|
||||
Acknowledged = bool.TryParse(parts.ElementAtOrDefault(3), out var acknowledged) && acknowledged,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WarningWire
|
||||
{
|
||||
public string WarningId { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int Category { get; set; }
|
||||
public string CreatedAt { get; set; } = string.Empty;
|
||||
public bool Acknowledged { get; set; }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user