mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94781fd11a | |||
| 99f8c2e016 | |||
| d4ce92f1b4 | |||
| c7a087eea1 | |||
| 5bfa463528 | |||
| 891ec89899 | |||
| f0e4679887 | |||
| 260f6605fc | |||
| 12da61407d | |||
| 5b93af4ba9 | |||
| 51104464f0 | |||
| 35768c05ed | |||
| 0a5c3a9a64 | |||
| fd93b4b028 | |||
| 8be0f90dbc | |||
| d0d26c9821 | |||
| 503d838fd1 | |||
| bb22675a91 | |||
| c1000c82aa | |||
| 9076fb9fae | |||
| cf7a021898 | |||
| e29707deeb | |||
| 3b332a6476 | |||
| d295fd5c37 | |||
| 8e12db660b | |||
| bafb380fc8 | |||
| 78c64d117a | |||
| e7c0b076f2 | |||
| 0a3bad7c6c | |||
| 1718912a07 | |||
| 5acad4c9e2 | |||
| ec88a39750 | |||
| 6e5b011059 | |||
| 2e09042344 | |||
| 9c5b574d6e | |||
| 29aca0e88c | |||
| eb73f3c484 | |||
| cc7f77b7de | |||
| 19402459a9 | |||
| 3266ce47f3 | |||
| cf3e6ea025 | |||
| bf0f09ce84 | |||
| fd2153d82b | |||
| 0839d932ce | |||
| 9523525cb8 | |||
| 3880745efd | |||
| 31d5a022c8 | |||
| 83b896ffaf | |||
| fcc734726a | |||
| b1c1239165 | |||
| afad9bbc5a | |||
| e33145307f | |||
| 38335c06a9 | |||
| 5ab28b1d4f | |||
| d60b0642a8 | |||
| a7879d7ede | |||
| 6272844445 | |||
| 2dbd03b9ae | |||
| f4b4836375 | |||
| ee7d614262 | |||
| 93b4e43316 | |||
| 975753deac | |||
| cebe7ae3ee | |||
| 8373e592f4 | |||
| 0c899a1001 | |||
| 34d4c9d115 | |||
| 6ee1d6f9b3 | |||
| 36e64e99ba | |||
| f2e7532853 | |||
| 1b3edf008d | |||
| e6e5c9990f | |||
| 1d7801b3c0 | |||
| bb04782e11 |
@@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 9.0.x
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
- name: Install protobuf compiler
|
- name: Install protobuf compiler
|
||||||
run: |
|
run: |
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Start server
|
- name: Start server
|
||||||
run: |
|
run: |
|
||||||
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
dotnet DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||||
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
|
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
|
||||||
|
|
||||||
- name: Wait for server readiness
|
- name: Wait for server readiness
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
- name: Run endpoint contract
|
- name: Run endpoint contract
|
||||||
run: |
|
run: |
|
||||||
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
|
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
|
||||||
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
|
--server-assembly DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll \
|
||||||
--base-url http://127.0.0.1:18080 \
|
--base-url http://127.0.0.1:18080 \
|
||||||
--report "$RUNNER_TEMP/endpoint-contract-report.json"
|
--report "$RUNNER_TEMP/endpoint-contract-report.json"
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
# ── Server self-contained publish ────────────────────────────────
|
# ── Server self-contained publish ────────────────────────────────
|
||||||
- name: Publish DorkNet.Server (win-x64, self-contained)
|
- name: Publish DorkNet.Server (win-x64, self-contained)
|
||||||
@@ -43,10 +43,10 @@ jobs:
|
|||||||
Compress-Archive -Path publish/server/* `
|
Compress-Archive -Path publish/server/* `
|
||||||
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||||
|
|
||||||
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
|
# ── Client patcher zip (built via tools/build_pc.py) ─────────────
|
||||||
- name: Build client patcher zip
|
- name: Build client patcher zip
|
||||||
run: |
|
run: |
|
||||||
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
|
python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
|
||||||
|
|
||||||
# ── Publish both as Release assets ───────────────────────────────
|
# ── Publish both as Release assets ───────────────────────────────
|
||||||
- name: Upload Release assets
|
- name: Upload Release assets
|
||||||
|
|||||||
+2
-2
@@ -12,8 +12,8 @@ dist/
|
|||||||
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
||||||
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
||||||
# case-insensitive filesystems. Use the precise runtime data dirs.
|
# case-insensitive filesystems. Use the precise runtime data dirs.
|
||||||
DorkNet.Server/bin/Debug/net9.0/data/
|
DorkNet.Server/bin/Debug/net10.0/data/
|
||||||
DorkNet.Server/bin/Release/net9.0/data/
|
DorkNet.Server/bin/Release/net10.0/data/
|
||||||
# appsettings.Production.json is intentionally versioned — contains
|
# appsettings.Production.json is intentionally versioned — contains
|
||||||
# only the Postgres provider switch + Kestrel bind address. All real
|
# only the Postgres provider switch + Kestrel bind address. All real
|
||||||
# secrets come from Coolify env vars at runtime.
|
# secrets come from Coolify env vars at runtime.
|
||||||
|
|||||||
+5
-5
@@ -1,15 +1,15 @@
|
|||||||
# DorkNet.Server container image — Coolify deploy target.
|
# DorkNet.Server container image — Coolify deploy target.
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
# Multi-stage: SDK image builds + publishes, runtime image is the
|
# Multi-stage: SDK image builds + publishes, runtime image is the
|
||||||
# slimmer aspnet:9.0 with only the published output.
|
# slimmer aspnet:10.0 with only the published output.
|
||||||
#
|
#
|
||||||
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
||||||
# to ProjectReference + dotnet restore). The .dockerignore at the
|
# to ProjectReference + dotnet restore). The .dockerignore at the
|
||||||
# repo root keeps the bin/, obj/, and other dev junk out of the
|
# repo root keeps the bin/, obj/, and other dev junk out of the
|
||||||
# context.
|
# context.
|
||||||
|
|
||||||
ARG DOTNET_VERSION=9.0
|
ARG DOTNET_VERSION=10.0
|
||||||
ARG NODE_VERSION=20
|
ARG NODE_VERSION=22
|
||||||
|
|
||||||
# ── admin SPA build stage ──────────────────────────────────────────
|
# ── admin SPA build stage ──────────────────────────────────────────
|
||||||
# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a
|
# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a
|
||||||
@@ -76,13 +76,13 @@ WORKDIR /app
|
|||||||
|
|
||||||
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
||||||
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
||||||
# both have to be present. The aspnet:9.0 base image strips both by
|
# both have to be present. The aspnet:10.0 base image strips both by
|
||||||
# default to keep the surface area small.
|
# default to keep the surface area small.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends curl wget \
|
&& apt-get install -y --no-install-recommends curl wget \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
|
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
|
||||||
# Coolify mounts persistent volumes with write permission for this UID.
|
# Coolify mounts persistent volumes with write permission for this UID.
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
ARG DOTNET_VERSION=9.0
|
ARG DOTNET_VERSION=10.0
|
||||||
ARG NODE_VERSION=20
|
ARG NODE_VERSION=22
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||||
WORKDIR /ui
|
WORKDIR /ui
|
||||||
|
|||||||
+155
-3
@@ -6,8 +6,9 @@
|
|||||||
// 2. 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)
|
// 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
|
// needed so a modded client isn't kicked, and three opt-in features that
|
||||||
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size).
|
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size,
|
||||||
|
// Rec Royale night mode).
|
||||||
//
|
//
|
||||||
// This is the STABLE, shipping mod: it carries no diagnostic/tracing
|
// This is the STABLE, shipping mod: it carries no diagnostic/tracing
|
||||||
// code. All of that lives in the separate DorkNet.DebugMod (drop it in
|
// code. All of that lives in the separate DorkNet.DebugMod (drop it in
|
||||||
@@ -109,6 +110,60 @@ public class Mod : MelonMod
|
|||||||
// generic "Quest" config is intentionally NOT in the default list.
|
// generic "Quest" config is intentionally NOT in the default list.
|
||||||
public static string[] QuestMaxTeamSizeRooms =
|
public static string[] QuestMaxTeamSizeRooms =
|
||||||
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
|
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
|
||||||
|
|
||||||
|
// ── Rec Royale night mode (client-side visual) ──
|
||||||
|
// Re-skins Rec Royale with one of the game's own shipped night
|
||||||
|
// MoodSetting assets (sky dome, sun, fog, bloom, colour grading)
|
||||||
|
// through SceneMoodSettings' apply path, and unlocks the weapon
|
||||||
|
// flashlight prefab while it is on. Purely local: the networked room
|
||||||
|
// mood index is never touched. See RecRoyaleNight.
|
||||||
|
public static bool RecRoyaleNightMode = false;
|
||||||
|
// Resources asset name of the MoodSetting to apply. Shipped options:
|
||||||
|
// Night_Calm_Outdoor_Mood, Night_Spooky_Outdoor_Mood,
|
||||||
|
// Night_Wild_Outdoor_Mood, Night_StuntRunner_Outdoor_Mood, OuterSpace_Mood.
|
||||||
|
public static string RecRoyaleNightMood = "Night_Calm_Outdoor_Mood";
|
||||||
|
// Unity scene names (substring, case-insensitive) that count as Rec
|
||||||
|
// Royale. 2023-03-21 has a single map: RecRoyale_Frontier.
|
||||||
|
public static string[] RecRoyaleNightScenes = { "RecRoyale" };
|
||||||
|
// Force WeaponFlashlightController.FlashlightAllowed while night is on.
|
||||||
|
public static bool RecRoyaleNightFlashlights = true;
|
||||||
|
// Apply the mood's fog block too. Off = the mood's FogSettings are
|
||||||
|
// swapped for a block captured from the scene, so fog stays as-is.
|
||||||
|
public static bool RecRoyaleNightFog = false;
|
||||||
|
// Darkening knobs applied to the mood data before it is used. Baked
|
||||||
|
// lightmaps can't be dimmed, so post-exposure (EV, added to the mood's
|
||||||
|
// ColorGradingSettings.exposure) is what actually darkens the whole
|
||||||
|
// frame; the sun/ambient multipliers tone down realtime lighting.
|
||||||
|
public static float RecRoyaleNightExposure = -3.5f; // EV offset (0 = mood default)
|
||||||
|
public static float RecRoyaleNightSunScale = 0.15f; // × SunSettings.sunIntensity (1 = unchanged)
|
||||||
|
public static float RecRoyaleNightAmbientScale = 0.2f; // × SkyBoxSettings.ambientLightIntensity (1 = unchanged)
|
||||||
|
// Post-exposure only renders if the scene Volume is on and the camera
|
||||||
|
// renders post-processing; force both while night mode is active.
|
||||||
|
public static bool RecRoyaleNightForcePostProcessing = true;
|
||||||
|
// The engine only drives RenderSettings.sun (Frontier: the sun-disc
|
||||||
|
// light). Copy its rotation/colour onto every other directional light
|
||||||
|
// and scale their intensity by RecRoyaleNightSunScale.
|
||||||
|
public static bool RecRoyaleNightMatchSunLights = true;
|
||||||
|
// Turn fog off entirely while night is on (RenderSettings.fog=false and
|
||||||
|
// the mood's fog block pushed out of range). Ignored when
|
||||||
|
// RecRoyaleNightFog is true.
|
||||||
|
public static bool RecRoyaleNightDisableFog = false;
|
||||||
|
// Renderers whose GameObject or material name contains any of these
|
||||||
|
// (case-insensitive) are hidden while night is on. Frontier's daytime
|
||||||
|
// cloud meshes are CloudPlane/Clouds/…_Cloud_RecRoyale_Frontier_Mat.
|
||||||
|
public static string[] RecRoyaleNightHideObjects = { "Cloud" };
|
||||||
|
// Optional UnityEngine.KeyCode name to toggle night at runtime (e.g.
|
||||||
|
// "N"). Empty = no hotkey. A hotkey also arms the feature when
|
||||||
|
// RecRoyaleNightMode is false, so it can be switched on in-game.
|
||||||
|
public static string RecRoyaleNightToggleKey = "";
|
||||||
|
|
||||||
|
// A client with no remembered account for this machine. false: the
|
||||||
|
// title screen opens the sign-in page so the player logs into their
|
||||||
|
// existing account. true: Rec Room's own behaviour, which creates a
|
||||||
|
// new account automatically -- only useful when the server allows
|
||||||
|
// sign-ups (otherwise the title screen keeps retrying and failing).
|
||||||
|
// See TitleScreenLoginPatches.
|
||||||
|
public static bool AutoCreateAccount = false;
|
||||||
}
|
}
|
||||||
public override void OnInitializeMelon()
|
public override void OnInitializeMelon()
|
||||||
{
|
{
|
||||||
@@ -178,6 +233,27 @@ public class Mod : MelonMod
|
|||||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Title-screen auto-login: with no remembered account, open the
|
||||||
|
// sign-in page instead of creating a new account.
|
||||||
|
if (!Cfg.AutoCreateAccount)
|
||||||
|
{
|
||||||
|
TryPatchByName("RRUI.Data.TitleScreenFlowModel", "IAKHIJIHAPJ",
|
||||||
|
prefix: nameof(TitleScreenLoginPatches.AutoLogin_Prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rec Royale night mode: armed when enabled OR when a hotkey is set
|
||||||
|
// (so it can be switched on in-game). Scene detection + mood apply
|
||||||
|
// run from OnSceneWasLoaded / OnUpdate below.
|
||||||
|
if (Cfg.RecRoyaleNightMode || !string.IsNullOrWhiteSpace(Cfg.RecRoyaleNightToggleKey))
|
||||||
|
{
|
||||||
|
_recRoyaleNightArmed = true;
|
||||||
|
RecRoyaleNight.Register(HarmonyInstance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private bool _recRoyaleNightArmed;
|
||||||
|
public override void OnSceneWasLoaded(int buildIndex, string sceneName)
|
||||||
|
{
|
||||||
|
if (_recRoyaleNightArmed) RecRoyaleNight.OnSceneLoaded(sceneName);
|
||||||
}
|
}
|
||||||
private bool PatchFileHashCheckerCallback()
|
private bool PatchFileHashCheckerCallback()
|
||||||
{
|
{
|
||||||
@@ -359,6 +435,8 @@ public class Mod : MelonMod
|
|||||||
}
|
}
|
||||||
public override void OnUpdate()
|
public override void OnUpdate()
|
||||||
{
|
{
|
||||||
|
if (_recRoyaleNightArmed) RecRoyaleNight.Tick();
|
||||||
|
|
||||||
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
|
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
|
||||||
{
|
{
|
||||||
_antiTamperCallbackRetryFrame = 0;
|
_antiTamperCallbackRetryFrame = 0;
|
||||||
@@ -395,6 +473,7 @@ public class Mod : MelonMod
|
|||||||
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||||
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||||
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||||
|
if (TryGetConfigValue(r, "AutoCreateAccount", out v)) Cfg.AutoCreateAccount = v.GetBoolean();
|
||||||
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||||
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
@@ -404,6 +483,33 @@ public class Mod : MelonMod
|
|||||||
rooms.Add(e.GetString()!);
|
rooms.Add(e.GetString()!);
|
||||||
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
|
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
|
||||||
}
|
}
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightMode", out v)) Cfg.RecRoyaleNightMode = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightMood", out v)) Cfg.RecRoyaleNightMood = v.GetString() ?? Cfg.RecRoyaleNightMood;
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightFlashlights", out v)) Cfg.RecRoyaleNightFlashlights = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightFog", out v)) Cfg.RecRoyaleNightFog = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightExposure", out v)) Cfg.RecRoyaleNightExposure = (float)v.GetDouble();
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightSunScale", out v)) Cfg.RecRoyaleNightSunScale = (float)v.GetDouble();
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightAmbientScale", out v)) Cfg.RecRoyaleNightAmbientScale = (float)v.GetDouble();
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightForcePostProcessing", out v)) Cfg.RecRoyaleNightForcePostProcessing = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightMatchSunLights", out v)) Cfg.RecRoyaleNightMatchSunLights = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightDisableFog", out v)) Cfg.RecRoyaleNightDisableFog = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightHideObjects", out v) && v.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
var names = new List<string>();
|
||||||
|
foreach (var e in v.EnumerateArray())
|
||||||
|
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||||
|
names.Add(e.GetString()!);
|
||||||
|
Cfg.RecRoyaleNightHideObjects = names.ToArray(); // explicit (incl. empty = hide nothing)
|
||||||
|
}
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightToggleKey", out v)) Cfg.RecRoyaleNightToggleKey = v.GetString() ?? "";
|
||||||
|
if (TryGetConfigValue(r, "RecRoyaleNightScenes", out v) && v.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
var scenes = new List<string>();
|
||||||
|
foreach (var e in v.EnumerateArray())
|
||||||
|
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||||
|
scenes.Add(e.GetString()!);
|
||||||
|
if (scenes.Count > 0) Cfg.RecRoyaleNightScenes = scenes.ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -573,7 +679,7 @@ public class Mod : MelonMod
|
|||||||
private MethodInfo GetPatchMethod(string name)
|
private MethodInfo GetPatchMethod(string name)
|
||||||
{
|
{
|
||||||
// Look in the core + feature patch holder classes.
|
// 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) })
|
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight), typeof(TitleScreenLoginPatches) })
|
||||||
{
|
{
|
||||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
if (m is not null) return m;
|
if (m is not null) return m;
|
||||||
@@ -814,6 +920,52 @@ internal static class FileHashCheckerPatches
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
internal static class TitleScreenLoginPatches
|
||||||
|
{
|
||||||
|
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time
|
||||||
|
// auto-login: the title screen downloads this machine's remembered
|
||||||
|
// accounts (CFILOMKCAPL), picks one (BFHOLLPGBPB) and logs into it here.
|
||||||
|
// With none (null) it creates a brand-new account instead (PJMGFADOEEM ->
|
||||||
|
// grant_type create_account). A server with sign-ups off refuses that, the
|
||||||
|
// error handler (IBDFENJNMAA) shows the connection-error page and the flow
|
||||||
|
// tries again, forever. Open the sign-in page instead (GoToLogin, what the
|
||||||
|
// "Log in" button does). ManuallyLogin carries the player into the game
|
||||||
|
// from there on its own, so the boot step gets a promise that never
|
||||||
|
// settles (MAMJDBJDFAF, the game's non-generic promise).
|
||||||
|
private static Type? _promiseType;
|
||||||
|
|
||||||
|
public static bool AutoLogin_Prefix(object __instance, object? __0, MethodBase __originalMethod, ref object? __result)
|
||||||
|
{
|
||||||
|
if (__0 is not null || Mod.Cfg.AutoCreateAccount) return true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_promiseType ??= Mod.ResolveType("MAMJDBJDFAF");
|
||||||
|
var goToLogin = __instance.GetType().GetMethod("GoToLogin", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null);
|
||||||
|
if (_promiseType is null || goToLogin is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[title] no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Il2CppInterop wraps the interface return type (LDGADANDBIO) as
|
||||||
|
// its own class, so hand back the new promise's native object
|
||||||
|
// wrapped in that type.
|
||||||
|
var pending = (Il2CppObjectBase)Activator.CreateInstance(_promiseType)!;
|
||||||
|
var returnType = ((MethodInfo)__originalMethod).ReturnType;
|
||||||
|
var result = Activator.CreateInstance(returnType, pending.Pointer);
|
||||||
|
goToLogin.Invoke(__instance, null);
|
||||||
|
__result = result;
|
||||||
|
Mod.Log.Msg("[title] no remembered account; opened the sign-in page instead of creating a new account");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// goToLogin.Invoke wraps the real failure in TargetInvocationException.
|
||||||
|
var cause = ex is TargetInvocationException { InnerException: not null } tie ? tie.InnerException! : ex;
|
||||||
|
Mod.Log.Warning($"[title] sign-in redirect failed, creating an account as Rec Room does: {cause.GetType().Name}: {cause.Message}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
internal static class ToxModPatches
|
internal static class ToxModPatches
|
||||||
{
|
{
|
||||||
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
||||||
|
|||||||
@@ -0,0 +1,1401 @@
|
|||||||
|
// Rec Royale night mode — client-side, opt-in.
|
||||||
|
//
|
||||||
|
// Rec Royale never shipped a night variant, but the March-2023 client
|
||||||
|
// carries everything needed to build one from the game's own tooling:
|
||||||
|
//
|
||||||
|
// • Every room scene owns a RecRoomSceneManager (name-preserved field
|
||||||
|
// `sceneMoodSettings` → RecRoom.Core.Scene.SceneMoodSettings). That
|
||||||
|
// object is the room "mood" engine behind the Maker Pen sky/sun/fog
|
||||||
|
// settings: it holds `availableMoods` (MoodSetting[]), `defaultMood`
|
||||||
|
// and `CurrentMood`, plus blenders for skybox, sun, fog, bloom and
|
||||||
|
// colour grading.
|
||||||
|
// • SceneMoodSettings.MLLBALMKEGJ(MoodSetting) is the apply step
|
||||||
|
// (ISIL: SceneMoodSettings.txt:3025-3581). It pushes the mood's six
|
||||||
|
// settings blocks into the blenders (ALCMNGKOEBK), swaps the URP
|
||||||
|
// VolumeProfile (OEKNGEMDMDB), sets RenderSettings.skybox to the
|
||||||
|
// global MoodConfig skybox material (LNMDIIFOFPD.NHAEHKJINFH, shader
|
||||||
|
// "Custom/SkyBox"), re-aims RenderSettings.sun, sets every
|
||||||
|
// ReflectionProbe.customBakedTexture to the mood's baked cubemap and
|
||||||
|
// force-updates the blenders. It does NOT touch the networked mood
|
||||||
|
// index, so calling it is purely local.
|
||||||
|
// • The build ships ready-made night moods as Resources assets
|
||||||
|
// (resources.assets; ResourceManager table in globalgamemanagers):
|
||||||
|
// Night_Calm_Outdoor_Mood ("Calm Night"), Night_Spooky_Outdoor_Mood
|
||||||
|
// ("Spooky Night"), Night_Wild_Outdoor_Mood ("Wild Night"),
|
||||||
|
// Night_StuntRunner_Outdoor_Mood and OuterSpace_Mood ("Outer Space").
|
||||||
|
// • The only Rec Royale map in this build is
|
||||||
|
// Assets/Activities/RecRoyale/Maps/RecRoyale_Frontier/RecRoyale_Frontier.unity.
|
||||||
|
//
|
||||||
|
// RRO SCENES HAVE NO ROOM DATA BLOB. The mood engine's `isEnabled` flag
|
||||||
|
// (SceneMoodSettings+0x38) is only switched on by the room-data loader
|
||||||
|
// (EIMFCPIHEPF.JPKEMACDNCF, ISIL :2136-2310), which runs for custom rooms
|
||||||
|
// only. While it is off, get_CurrentMood returns null and nothing ticks.
|
||||||
|
// The loader's bring-up sequence is:
|
||||||
|
// LKIKMLNPCFK(false) // SetEnabled(false): applies a null mood
|
||||||
|
// LIEDFOLBGHL() // capture the scene's own skybox/sun/fog as the
|
||||||
|
// // blenders' "previous" data (checks shader name
|
||||||
|
// // "Custom/SkyBox" → HasRecRoomSkyboxShader)
|
||||||
|
// LKIKMLNPCFK(true) // SetEnabled(true)
|
||||||
|
// This mod replays exactly that before applying the night mood.
|
||||||
|
//
|
||||||
|
// Deliberately NOT used: SceneMoodSettings.IPAMPMBDPNB(Guid) /
|
||||||
|
// set_CurrentRoomMoodIndex. `_currentRoomMoodIndex` is a
|
||||||
|
// RecRoom.Networking.SynchronizedFields DFAOKLDJPEL<int>, i.e. it
|
||||||
|
// replicates to every player in the instance. Applying the mood directly
|
||||||
|
// keeps night mode a per-client visual choice.
|
||||||
|
//
|
||||||
|
// Weapon flashlights: WeaponFlashlightController.get_FlashlightAllowed
|
||||||
|
// (ISIL: WeaponFlashlightController.txt:3-75) is
|
||||||
|
// `Matchmaking.FBELDNLEKAC() != null && [+0x28] == 15` — a game-mode
|
||||||
|
// gate for an activity that isn't Rec Royale. A Harmony postfix forces it
|
||||||
|
// true while night mode is active, so weapons picked up in the dark get
|
||||||
|
// the game's own flashlight prefab.
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using HarmonyLib;
|
||||||
|
using Il2CppInterop.Runtime;
|
||||||
|
using Il2CppInterop.Runtime.InteropTypes;
|
||||||
|
|
||||||
|
namespace DorkNet.ClientMod;
|
||||||
|
|
||||||
|
internal static class RecRoyaleNight
|
||||||
|
{
|
||||||
|
private const BindingFlags Any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||||
|
|
||||||
|
// Runtime state. `Active` starts from Cfg.RecRoyaleNightMode and flips
|
||||||
|
// with the hotkey; `_inRoyale` tracks whether the current scene is one
|
||||||
|
// of Cfg.RecRoyaleNightScenes.
|
||||||
|
public static bool Active { get; private set; }
|
||||||
|
private static bool _inRoyale;
|
||||||
|
private static string _sceneName = "";
|
||||||
|
private static bool _appliedThisScene;
|
||||||
|
private static int _frame;
|
||||||
|
private static int _sceneFrames; // frames since the Royale scene loaded
|
||||||
|
private static int _reassertFrames; // frames since the last re-apply
|
||||||
|
|
||||||
|
// Per-scene bring-up bookkeeping so toggling off can put things back.
|
||||||
|
private static bool _engineWasDisabled; // we enabled the mood engine ourselves
|
||||||
|
private static object? _originalSkybox; // RenderSettings.skybox before we touched it
|
||||||
|
private static bool _sunWasNull; // we assigned RenderSettings.sun ourselves
|
||||||
|
private static object? _originalFog; // MoodSetting.FogSettings before we swapped it
|
||||||
|
private static object? _fogPatchedMood; // the mood asset whose FogSettings we replaced
|
||||||
|
private static object? _darkPatchedMood; // the mood asset whose exposure/sun/ambient we changed
|
||||||
|
private static float? _origExposure, _origSunIntensity, _origAmbient;
|
||||||
|
private static bool _diagLogged;
|
||||||
|
|
||||||
|
private const int PollEveryFrames = 30; // ~0.5 s @ 60 fps
|
||||||
|
private const int SettleFrames = 90; // let the scene's own lighting settle first
|
||||||
|
private const int PollGiveUpFrames = 60 * 120; // stop polling after ~2 min
|
||||||
|
private const int ReassertEveryFrames = 60 * 10; // re-apply every ~10 s
|
||||||
|
|
||||||
|
// Resolved reflection handles (lazy, cached).
|
||||||
|
private static bool _resolved;
|
||||||
|
private static Type? _sceneManagerType;
|
||||||
|
private static PropertyInfo? _sceneManagerInstance;
|
||||||
|
private static FieldInfo? _sceneMoodSettingsField;
|
||||||
|
private static PropertyInfo? _sceneMoodSettingsProp;
|
||||||
|
private static Type? _moodSettingType;
|
||||||
|
private static MethodInfo? _applyMood; // MLLBALMKEGJ(MoodSetting)
|
||||||
|
private static MethodInfo? _setEnabled; // LKIKMLNPCFK(bool)
|
||||||
|
private static MethodInfo? _capture; // LIEDFOLBGHL()
|
||||||
|
private static FieldInfo? _isEnabled; // isEnabled
|
||||||
|
private static PropertyInfo? _roomMoodsEnabled;
|
||||||
|
private static PropertyInfo? _hasSkyShader;
|
||||||
|
private static PropertyInfo? _hasSunSource;
|
||||||
|
private static PropertyInfo? _volume;
|
||||||
|
private static FieldInfo? _availableMoods;
|
||||||
|
private static PropertyInfo? _currentMood;
|
||||||
|
private static PropertyInfo? _friendlyName;
|
||||||
|
private static PropertyInfo? _renderSkybox; // UnityEngine.RenderSettings.skybox
|
||||||
|
private static PropertyInfo? _renderSun; // UnityEngine.RenderSettings.sun
|
||||||
|
private static Type? _lightType; // UnityEngine.Light
|
||||||
|
private static Type? _fogSettingsType; // FogMoodSettings
|
||||||
|
private static MethodInfo? _fogCapture; // FogMoodSettings.NLDDIDEIEFH() — read RenderSettings fog
|
||||||
|
private static PropertyInfo? _moodFog; // MoodSetting.FogSettings
|
||||||
|
private static Type? _materialType; // UnityEngine.Material
|
||||||
|
private static Type? _shaderType; // UnityEngine.Shader
|
||||||
|
private static Type? _moodConfigHolder; // LNMDIIFOFPD (static MoodConfig holder)
|
||||||
|
private static Type? _cameraDataType; // UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
|
||||||
|
private static object? _ourSkybox; // Material we created for the mood skybox
|
||||||
|
private static object? _nightMood; // MoodSetting interop wrapper
|
||||||
|
private static string _nightMoodLoadedName = "";
|
||||||
|
|
||||||
|
// Hotkey
|
||||||
|
private static bool _inputResolved;
|
||||||
|
private static MethodInfo? _getKeyDown;
|
||||||
|
private static object? _toggleKey;
|
||||||
|
|
||||||
|
// ── registration ──────────────────────────────────────────────────
|
||||||
|
public static void Register(HarmonyLib.Harmony harmony)
|
||||||
|
{
|
||||||
|
Active = Mod.Cfg.RecRoyaleNightMode;
|
||||||
|
Mod.Log.Msg($"[royale-night] enabled={Active} mood={Mod.Cfg.RecRoyaleNightMood} " +
|
||||||
|
$"scenes=[{string.Join(",", Mod.Cfg.RecRoyaleNightScenes)}] " +
|
||||||
|
$"flashlights={Mod.Cfg.RecRoyaleNightFlashlights} " +
|
||||||
|
$"hotkey={(string.IsNullOrWhiteSpace(Mod.Cfg.RecRoyaleNightToggleKey) ? "<none>" : Mod.Cfg.RecRoyaleNightToggleKey)}");
|
||||||
|
|
||||||
|
if (Mod.Cfg.RecRoyaleNightFlashlights)
|
||||||
|
TryPatchFlashlightGate(harmony);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POSTFIX on WeaponFlashlightController.get_FlashlightAllowed. The
|
||||||
|
// interop property carries the obfuscated name (KIMMMCCKMJL in the
|
||||||
|
// 2023-03-21 build) while the il2cpp method keeps `get_FlashlightAllowed`,
|
||||||
|
// so resolve by name first and fall back to "the only read-only bool
|
||||||
|
// property declared on the type".
|
||||||
|
private static void TryPatchFlashlightGate(HarmonyLib.Harmony harmony)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = Mod.ResolveType("WeaponFlashlightController");
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[patch-miss] WeaponFlashlightController: type not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MethodInfo? getter = AccessTools.PropertyGetter(type, "FlashlightAllowed")
|
||||||
|
?? AccessTools.PropertyGetter(type, "KIMMMCCKMJL");
|
||||||
|
if (getter is null)
|
||||||
|
{
|
||||||
|
PropertyInfo? only = null;
|
||||||
|
int count = 0;
|
||||||
|
foreach (var p in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||||
|
{
|
||||||
|
if (p.PropertyType != typeof(bool) || !p.CanRead || p.CanWrite || p.GetIndexParameters().Length != 0) continue;
|
||||||
|
only = p; count++;
|
||||||
|
}
|
||||||
|
if (count == 1) getter = only!.GetGetMethod(true);
|
||||||
|
}
|
||||||
|
if (getter is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[patch-miss] WeaponFlashlightController.get_FlashlightAllowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var postfix = typeof(RecRoyaleNight).GetMethod(nameof(FlashlightAllowed_Postfix), BindingFlags.Public | BindingFlags.Static)!;
|
||||||
|
harmony.Patch(getter, postfix: new HarmonyMethod(postfix));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Error($"[patch-fail] WeaponFlashlightController.get_FlashlightAllowed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void FlashlightAllowed_Postfix(ref bool __result)
|
||||||
|
{
|
||||||
|
if (Active && _inRoyale) __result = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── lifecycle (called from Mod) ───────────────────────────────────
|
||||||
|
public static void OnSceneLoaded(string sceneName)
|
||||||
|
{
|
||||||
|
_sceneName = sceneName ?? "";
|
||||||
|
var was = _inRoyale;
|
||||||
|
_inRoyale = MatchesScene(_sceneName);
|
||||||
|
if (_inRoyale)
|
||||||
|
{
|
||||||
|
_appliedThisScene = false;
|
||||||
|
_sceneFrames = 0;
|
||||||
|
_reassertFrames = 0;
|
||||||
|
_engineWasDisabled = false;
|
||||||
|
_originalSkybox = null;
|
||||||
|
_sunWasNull = false;
|
||||||
|
_diagLogged = false;
|
||||||
|
RestoreMoodPatches();
|
||||||
|
Mod.Log.Msg($"[royale-night] scene '{_sceneName}' loaded — night mode {(Active ? "will apply" : "is off (hotkey to enable)")}");
|
||||||
|
}
|
||||||
|
else if (was)
|
||||||
|
{
|
||||||
|
_appliedThisScene = false;
|
||||||
|
_engineWasDisabled = false;
|
||||||
|
_originalSkybox = null;
|
||||||
|
_sunWasNull = false;
|
||||||
|
RestoreMoodPatches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Tick()
|
||||||
|
{
|
||||||
|
_frame++;
|
||||||
|
PollHotkey();
|
||||||
|
|
||||||
|
if (!_inRoyale || !Active) return;
|
||||||
|
_sceneFrames++;
|
||||||
|
if (_frame % PollEveryFrames != 0) return;
|
||||||
|
|
||||||
|
if (!_appliedThisScene)
|
||||||
|
{
|
||||||
|
if (_sceneFrames < SettleFrames || _sceneFrames > PollGiveUpFrames) return;
|
||||||
|
if (TryApply(true)) _appliedThisScene = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap safeguard: the game re-applies its own mood on quality
|
||||||
|
// changes / studio events. Re-assert every ~10 s; when the blenders
|
||||||
|
// are already at target this is visually a no-op.
|
||||||
|
_reassertFrames += PollEveryFrames;
|
||||||
|
if (_reassertFrames >= ReassertEveryFrames)
|
||||||
|
{
|
||||||
|
_reassertFrames = 0;
|
||||||
|
TryApply(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Toggle()
|
||||||
|
{
|
||||||
|
Active = !Active;
|
||||||
|
Mod.Log.Msg($"[royale-night] toggled {(Active ? "ON" : "OFF")}");
|
||||||
|
if (!_inRoyale) return;
|
||||||
|
if (TryApply(Active)) _appliedThisScene = Active;
|
||||||
|
else if (!Active) _appliedThisScene = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the actual apply ──────────────────────────────────────────────
|
||||||
|
// night=true → bring the mood engine up if needed, apply the night mood
|
||||||
|
// night=false → restore: scene mood if the engine was already running,
|
||||||
|
// otherwise disable the engine again + put the skybox back
|
||||||
|
private static bool TryApply(bool night)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_resolved && !Resolve()) return false;
|
||||||
|
if (_applyMood is null) return false;
|
||||||
|
|
||||||
|
var manager = GetSceneManager();
|
||||||
|
if (manager is null) { Diag("scene manager instance is null"); return false; }
|
||||||
|
|
||||||
|
var moods = _sceneMoodSettingsField?.GetValue(manager) ?? _sceneMoodSettingsProp?.GetValue(manager);
|
||||||
|
if (moods is null) { Diag("sceneMoodSettings is null"); return false; }
|
||||||
|
|
||||||
|
// `isEnabled` reads differently through the interop property vs the
|
||||||
|
// field (seen True/False for the same state), so don't gate on it.
|
||||||
|
// RoomMoodsEnabled (= isEnabled && defaultMood != null) is computed
|
||||||
|
// game-side and is false for every RRO scene; bring the engine up
|
||||||
|
// exactly once per Royale scene whenever it is not true.
|
||||||
|
var enabled = GetMember(moods, "isEnabled") is true;
|
||||||
|
var roomMoodsEnabled = Read(_roomMoodsEnabled, moods) is true;
|
||||||
|
LogState(moods, enabled);
|
||||||
|
|
||||||
|
if (night)
|
||||||
|
{
|
||||||
|
var target = GetNightMood();
|
||||||
|
if (target is null) return false;
|
||||||
|
|
||||||
|
if (!roomMoodsEnabled && !_engineWasDisabled)
|
||||||
|
{
|
||||||
|
// Replay the room-data loader's bring-up. Remember the
|
||||||
|
// scene's own skybox so toggle-off can restore it.
|
||||||
|
if (_originalSkybox is null && _renderSkybox is not null)
|
||||||
|
_originalSkybox = _renderSkybox.GetValue(null);
|
||||||
|
if (_setEnabled is null || _capture is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[royale-night] mood engine is disabled in this scene and SetEnabled/Capture are unresolved — cannot bring it up");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RRO scenes leave RenderSettings.sun unset, and the engine
|
||||||
|
// skips every sun change (direction, colour, intensity) when
|
||||||
|
// HasSunSource is false. Point it at the scene's directional
|
||||||
|
// light BEFORE capture so the flag comes out true.
|
||||||
|
EnsureSun();
|
||||||
|
|
||||||
|
// Keep the scene's own fog unless asked otherwise: replace
|
||||||
|
// the mood's FogSettings with a block captured from the
|
||||||
|
// current RenderSettings before anything changes them.
|
||||||
|
if (!Mod.Cfg.RecRoyaleNightFog) PreserveFog(target);
|
||||||
|
|
||||||
|
// Baked lightmaps can't be dimmed, so darken the frame via
|
||||||
|
// the mood's own post-exposure and tone down sun/ambient.
|
||||||
|
PatchDarkness(target);
|
||||||
|
|
||||||
|
_setEnabled.Invoke(moods, new object[] { false });
|
||||||
|
_capture.Invoke(moods, null);
|
||||||
|
|
||||||
|
// The per-frame update (CEKLEBHDAHA, ISIL :3895-4031) returns
|
||||||
|
// at once while get_CurrentMood() is null, and the one-shot
|
||||||
|
// apply only sets blend TARGETS — the update is what writes
|
||||||
|
// sky colours, rotates the sun and pushes exposure. In RRO
|
||||||
|
// scenes CurrentMood is null because defaultMood is unset.
|
||||||
|
// Register the night mood as this scene's defaultMood and
|
||||||
|
// sole availableMoods entry so the engine runs like a
|
||||||
|
// one-mood custom room.
|
||||||
|
InstallSceneMood(moods, target);
|
||||||
|
|
||||||
|
_setEnabled.Invoke(moods, new object[] { true });
|
||||||
|
_engineWasDisabled = true;
|
||||||
|
ProbeCircuitsSupport();
|
||||||
|
var cm = _currentMood?.GetValue(moods);
|
||||||
|
Mod.Log.Msg($"[royale-night] mood engine brought up (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)} " +
|
||||||
|
$"roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} currentMood={(cm is null || IsUnityNull(cm) ? "<null>" : Describe(cm))})");
|
||||||
|
|
||||||
|
// First apply swaps RenderSettings.skybox to
|
||||||
|
// MoodConfig.SkyBoxMaterial. In RRO scenes that material is
|
||||||
|
// not loaded (it lives in the custom-room scene's shared
|
||||||
|
// assets), so the skybox ends up null → black sky and the
|
||||||
|
// engine's HasRecRoomSkyboxShader / HasSunSource stay false
|
||||||
|
// (both need shader "Custom/SkyBox" on RenderSettings.skybox).
|
||||||
|
// Supply a material built from that shader, re-capture so
|
||||||
|
// the flags flip, then fall through to the real apply.
|
||||||
|
_applyMood.Invoke(moods, new[] { target });
|
||||||
|
EnsureMoodSkybox();
|
||||||
|
_capture.Invoke(moods, null);
|
||||||
|
Mod.Log.Msg($"[royale-night] re-captured (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} skybox={DescribeSkybox()})");
|
||||||
|
EnsurePostProcessing(moods);
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyMood.Invoke(moods, new[] { target });
|
||||||
|
if (Mod.Cfg.RecRoyaleNightMatchSunLights) MatchOtherSunLights();
|
||||||
|
if (Mod.Cfg.RecRoyaleNightDisableFog && !Mod.Cfg.RecRoyaleNightFog) ForceFogOff();
|
||||||
|
HideObjects();
|
||||||
|
Mod.Log.Msg($"[royale-night] applied mood '{Describe(target)}' in '{_sceneName}'");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// restore
|
||||||
|
if (_engineWasDisabled)
|
||||||
|
{
|
||||||
|
RestoreOtherSunLights();
|
||||||
|
ShowObjects();
|
||||||
|
RestoreFogState();
|
||||||
|
_setEnabled?.Invoke(moods, new object[] { false }); // applies the captured "previous" data
|
||||||
|
UninstallSceneMood(moods);
|
||||||
|
if (_originalSkybox is not null && _renderSkybox is not null)
|
||||||
|
_renderSkybox.SetValue(null, _originalSkybox);
|
||||||
|
if (_sunWasNull && _renderSun is not null)
|
||||||
|
_renderSun.SetValue(null, null);
|
||||||
|
RestoreMoodPatches();
|
||||||
|
_engineWasDisabled = false;
|
||||||
|
_originalSkybox = null;
|
||||||
|
_sunWasNull = false;
|
||||||
|
Mod.Log.Msg($"[royale-night] mood engine disabled again, scene skybox/sun restored in '{_sceneName}'");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = _currentMood?.GetValue(moods);
|
||||||
|
if (current is null || IsUnityNull(current)) { Diag("no CurrentMood to restore"); return false; }
|
||||||
|
_applyMood.Invoke(moods, new[] { current });
|
||||||
|
Mod.Log.Msg($"[royale-night] restored scene mood '{Describe(current)}' in '{_sceneName}'");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[royale-night] apply failed: {(ex.InnerException ?? ex).Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LogState(object moods, bool enabled)
|
||||||
|
{
|
||||||
|
if (_diagLogged) return;
|
||||||
|
_diagLogged = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var arr = GetMember(moods, "availableMoods");
|
||||||
|
var cur = _currentMood?.GetValue(moods);
|
||||||
|
Mod.Log.Msg($"[royale-night] engine state: isEnabled={enabled} roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} " +
|
||||||
|
$"availableMoods={Count(arr)} currentMood={(cur is null || IsUnityNull(cur) ? "<null>" : Describe(cur))} " +
|
||||||
|
$"skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] state log failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? Read(PropertyInfo? p, object o)
|
||||||
|
{
|
||||||
|
try { return p?.GetValue(o); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Diag(string why)
|
||||||
|
{
|
||||||
|
if (_frame % (PollEveryFrames * 20) == 0) Mod.Log.Msg($"[royale-night] waiting: {why}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Resolve()
|
||||||
|
{
|
||||||
|
_resolved = true;
|
||||||
|
|
||||||
|
_sceneManagerType = Mod.ResolveType("RecRoomSceneManager");
|
||||||
|
var moodSettingsType = Mod.ResolveType("RecRoom.Core.Scene.SceneMoodSettings");
|
||||||
|
_moodSettingType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodSetting");
|
||||||
|
if (_sceneManagerType is null || moodSettingsType is null || _moodSettingType is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[royale-night] types missing — sceneManager={_sceneManagerType is not null} " +
|
||||||
|
$"sceneMoodSettings={moodSettingsType is not null} moodSetting={_moodSettingType is not null}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecRoomSceneManager.Instance: the il2cpp getter is get_Instance but
|
||||||
|
// the interop property name is obfuscated (PDCOCJBICGC in 2023-03-21).
|
||||||
|
// Pick the static property whose type is RecRoomSceneManager.
|
||||||
|
_sceneManagerInstance = AccessTools.Property(_sceneManagerType, "Instance");
|
||||||
|
if (_sceneManagerInstance is null)
|
||||||
|
{
|
||||||
|
foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (p.PropertyType == _sceneManagerType && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||||
|
{ _sceneManagerInstance = p; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_sceneMoodSettingsField = AccessTools.Field(_sceneManagerType, "sceneMoodSettings");
|
||||||
|
if (_sceneMoodSettingsField is null)
|
||||||
|
{
|
||||||
|
foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||||
|
if (p.PropertyType == moodSettingsType && p.CanRead) { _sceneMoodSettingsProp = p; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentMood = AccessTools.Property(moodSettingsType, "CurrentMood");
|
||||||
|
_roomMoodsEnabled = AccessTools.Property(moodSettingsType, "RoomMoodsEnabled");
|
||||||
|
_hasSkyShader = AccessTools.Property(moodSettingsType, "HasRecRoomSkyboxShader");
|
||||||
|
_hasSunSource = AccessTools.Property(moodSettingsType, "HasSunSource");
|
||||||
|
_volume = AccessTools.Property(moodSettingsType, "Volume");
|
||||||
|
_availableMoods = AccessTools.Field(moodSettingsType, "availableMoods");
|
||||||
|
_isEnabled = AccessTools.Field(moodSettingsType, "isEnabled");
|
||||||
|
_friendlyName = AccessTools.Property(_moodSettingType, "FriendlyName");
|
||||||
|
|
||||||
|
// Apply step: MLLBALMKEGJ(MoodSetting) in 2023-03-21. Fallback: the
|
||||||
|
// only non-setter void method taking exactly one MoodSetting.
|
||||||
|
_applyMood = AccessTools.Method(moodSettingsType, "MLLBALMKEGJ", new[] { _moodSettingType });
|
||||||
|
if (_applyMood is null)
|
||||||
|
{
|
||||||
|
foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||||
|
{
|
||||||
|
if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
if (ps.Length == 1 && ps[0].ParameterType == _moodSettingType) { _applyMood = m; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEnabled(bool): LKIKMLNPCFK in 2023-03-21. Fallback: the only
|
||||||
|
// public non-setter void(bool) declared on the type.
|
||||||
|
_setEnabled = AccessTools.Method(moodSettingsType, "LKIKMLNPCFK", new[] { typeof(bool) });
|
||||||
|
if (_setEnabled is null)
|
||||||
|
{
|
||||||
|
MethodInfo? only = null; int n = 0;
|
||||||
|
foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||||
|
{
|
||||||
|
if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
if (ps.Length == 1 && ps[0].ParameterType == typeof(bool)) { only = m; n++; }
|
||||||
|
}
|
||||||
|
if (n == 1) _setEnabled = only;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture current scene lighting: LIEDFOLBGHL() in 2023-03-21. Too
|
||||||
|
// many void() methods on the type to pick by shape — name only.
|
||||||
|
_capture = AccessTools.Method(moodSettingsType, "LIEDFOLBGHL", Type.EmptyTypes);
|
||||||
|
|
||||||
|
var renderSettings = Mod.ResolveType("UnityEngine.RenderSettings");
|
||||||
|
_renderSkybox = renderSettings is null ? null : AccessTools.Property(renderSettings, "skybox");
|
||||||
|
_renderSun = renderSettings is null ? null : AccessTools.Property(renderSettings, "sun");
|
||||||
|
_lightType = Mod.ResolveType("UnityEngine.Light");
|
||||||
|
|
||||||
|
// Fog preservation: FogMoodSettings() + NLDDIDEIEFH() reads
|
||||||
|
// RenderSettings.fog/fogStartDistance/fogEndDistance/fogColor into the
|
||||||
|
// block (ISIL FogMoodSettings.txt, called right after the ctor in
|
||||||
|
// LIEDFOLBGHL). MoodSetting.FogSettings is the serialized reference
|
||||||
|
// the apply step hands to the fog blender.
|
||||||
|
_fogSettingsType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodBlendData.FogMoodSettings");
|
||||||
|
_fogCapture = _fogSettingsType is null ? null : AccessTools.Method(_fogSettingsType, "NLDDIDEIEFH", Type.EmptyTypes);
|
||||||
|
_moodFog = AccessTools.Property(_moodSettingType, "FogSettings");
|
||||||
|
|
||||||
|
_materialType = Mod.ResolveType("UnityEngine.Material");
|
||||||
|
_shaderType = Mod.ResolveType("UnityEngine.Shader");
|
||||||
|
_moodConfigHolder = Mod.ResolveType("LNMDIIFOFPD");
|
||||||
|
_cameraDataType = Mod.ResolveType("UnityEngine.Rendering.Universal.UniversalAdditionalCameraData");
|
||||||
|
|
||||||
|
var ok = _sceneManagerInstance is not null
|
||||||
|
&& (_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null)
|
||||||
|
&& _applyMood is not null;
|
||||||
|
Mod.Log.Msg($"[royale-night] resolved: instance={_sceneManagerInstance is not null} " +
|
||||||
|
$"moodSettings={_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null} " +
|
||||||
|
$"apply={_applyMood is not null} setEnabled={_setEnabled is not null} capture={_capture is not null} " +
|
||||||
|
$"isEnabled={_isEnabled is not null} currentMood={_currentMood is not null} skybox={_renderSkybox is not null} " +
|
||||||
|
$"sun={_renderSun is not null} light={_lightType is not null} fogCapture={_fogCapture is not null} moodFog={_moodFog is not null}");
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderSettings.sun ← the scene's directional Light when unset. Frontier
|
||||||
|
// ships without the reference ("Sun Source reference has not been set in
|
||||||
|
// Lighting settings" is the engine's own warning), so nothing sun-related
|
||||||
|
// from the mood would otherwise apply.
|
||||||
|
private static void EnsureSun()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_renderSun is null || _lightType is null) return;
|
||||||
|
var current = _renderSun.GetValue(null);
|
||||||
|
if (current is not null && !IsUnityNull(current))
|
||||||
|
{
|
||||||
|
Mod.Log.Msg($"[royale-night] RenderSettings.sun already set: '{ObjectName(current)}'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||||
|
var il2cppLight = Il2CppTypeOf(_lightType);
|
||||||
|
if (unityObject is null || il2cppLight is null) return;
|
||||||
|
|
||||||
|
object? directional = null;
|
||||||
|
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition) continue;
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
if (ps.Length != 1) continue;
|
||||||
|
var arr = m.Invoke(null, new object[] { il2cppLight });
|
||||||
|
var n = Count(arr);
|
||||||
|
float best = -1f;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var item = Item(arr, i);
|
||||||
|
if (item is null) continue;
|
||||||
|
var light = AsType(item, _lightType);
|
||||||
|
if (light is null) continue;
|
||||||
|
// LightType.Directional == 1
|
||||||
|
var kind = GetMember(light, "type");
|
||||||
|
if (kind is null || Convert.ToInt32(kind) != 1) continue;
|
||||||
|
var intensity = GetMember(light, "intensity") is float f ? f : 0f;
|
||||||
|
if (intensity > best) { best = intensity; directional = light; }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directional is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[royale-night] no directional Light found in scene — sun direction/colour will not change");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_renderSun.SetValue(null, directional);
|
||||||
|
_sunWasNull = true;
|
||||||
|
Mod.Log.Msg($"[royale-night] RenderSettings.sun ← '{ObjectName(directional)}'");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureSun failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap the mood asset's FogSettings for a block captured from the
|
||||||
|
// scene's current RenderSettings so applying the mood leaves fog as-is.
|
||||||
|
// The asset is shared for the session, so RestoreFog puts the original
|
||||||
|
// block back when we leave or toggle off.
|
||||||
|
private static void PreserveFog(object mood)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_fogSettingsType is null || _fogCapture is null || _moodFog is null || !_moodFog.CanWrite)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[royale-night] fog preservation unavailable (FogMoodSettings/NLDDIDEIEFH/FogSettings unresolved) — mood fog will apply");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ReferenceEquals(_fogPatchedMood, mood) && _originalFog is not null) return;
|
||||||
|
|
||||||
|
var captured = Activator.CreateInstance(_fogSettingsType);
|
||||||
|
if (captured is null) return;
|
||||||
|
_fogCapture.Invoke(captured, null);
|
||||||
|
if (Mod.Cfg.RecRoyaleNightDisableFog)
|
||||||
|
{
|
||||||
|
// The block has no exposed enabled flag; push it out of range so
|
||||||
|
// even if the blender re-enables fog each frame it is invisible.
|
||||||
|
SetMember(captured, "startDistance", 1_000_000f);
|
||||||
|
SetMember(captured, "fadeDistance", 1_000_000f);
|
||||||
|
}
|
||||||
|
|
||||||
|
_originalFog = _moodFog.GetValue(mood);
|
||||||
|
_fogPatchedMood = mood;
|
||||||
|
_moodFog.SetValue(mood, captured);
|
||||||
|
Mod.Log.Msg("[royale-night] mood fog replaced with the scene's own fog settings");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] PreserveFog failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RestoreFog()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_fogPatchedMood is not null && _originalFog is not null && _moodFog is not null && _moodFog.CanWrite)
|
||||||
|
_moodFog.SetValue(_fogPatchedMood, _originalFog);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_fogPatchedMood = null;
|
||||||
|
_originalFog = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RestoreMoodPatches()
|
||||||
|
{
|
||||||
|
RestoreFog();
|
||||||
|
RestoreDarkness();
|
||||||
|
RestoreOtherSunLights();
|
||||||
|
// Scene objects die with the scene; just forget them.
|
||||||
|
_probed = false;
|
||||||
|
_hidden.Clear();
|
||||||
|
_hideScanned = false;
|
||||||
|
_fogStateSaved = false;
|
||||||
|
_origFogEnabled = null;
|
||||||
|
// The scene's SceneMoodSettings dies with the scene; just forget it.
|
||||||
|
_sceneMoodInstalled = false;
|
||||||
|
_sceneMoodsObj = null;
|
||||||
|
_origDefaultMood = _origAvailableMoods = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The engine drives exactly one light: RenderSettings.sun (Frontier:
|
||||||
|
// "SkyboxLight", the sun-disc light). The map's actual key light is a
|
||||||
|
// separate directional Light it never touches, so daylight keeps
|
||||||
|
// casting. Copy the engine-driven sun's rotation and colour onto every
|
||||||
|
// other directional light and scale their intensity down. Originals are
|
||||||
|
// kept per light and restored on toggle-off / scene change.
|
||||||
|
private sealed class LightState
|
||||||
|
{
|
||||||
|
public object Light = null!;
|
||||||
|
public object? Intensity, Color, Rotation, ShadowStrength;
|
||||||
|
}
|
||||||
|
private static readonly List<LightState> _otherSuns = new();
|
||||||
|
|
||||||
|
private static void MatchOtherSunLights()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_renderSun is null || _lightType is null) return;
|
||||||
|
var sun = _renderSun.GetValue(null);
|
||||||
|
if (sun is null || IsUnityNull(sun)) return;
|
||||||
|
var sunPtr = (sun as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero;
|
||||||
|
var sunTransform = GetMember(sun, "transform");
|
||||||
|
var sunRotation = sunTransform is null ? null : GetMember(sunTransform, "rotation");
|
||||||
|
var sunColor = GetMember(sun, "color");
|
||||||
|
|
||||||
|
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||||
|
var il2cppLight = Il2CppTypeOf(_lightType);
|
||||||
|
if (unityObject is null || il2cppLight is null) return;
|
||||||
|
|
||||||
|
MethodInfo? findAll = null;
|
||||||
|
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
|
||||||
|
if (findAll is null) return;
|
||||||
|
|
||||||
|
var arr = findAll.Invoke(null, new object[] { il2cppLight });
|
||||||
|
var n = Count(arr);
|
||||||
|
var touched = new List<string>();
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var item = Item(arr, i);
|
||||||
|
if (item is null) continue;
|
||||||
|
var light = AsType(item, _lightType);
|
||||||
|
if (light is null) continue;
|
||||||
|
var kind = GetMember(light, "type");
|
||||||
|
if (kind is null || Convert.ToInt32(kind) != 1) continue; // Directional only
|
||||||
|
var ptr = (light as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero;
|
||||||
|
if (ptr != IntPtr.Zero && ptr == sunPtr) continue; // engine already drives this one
|
||||||
|
|
||||||
|
var state = _otherSuns.Find(s => (s.Light as Il2CppObjectBase)?.Pointer == ptr);
|
||||||
|
if (state is null)
|
||||||
|
{
|
||||||
|
state = new LightState
|
||||||
|
{
|
||||||
|
Light = light,
|
||||||
|
Intensity = GetMember(light, "intensity"),
|
||||||
|
Color = GetMember(light, "color"),
|
||||||
|
ShadowStrength = GetMember(light, "shadowStrength"),
|
||||||
|
Rotation = GetMember(light, "transform") is object tr ? GetMember(tr, "rotation") : null,
|
||||||
|
};
|
||||||
|
_otherSuns.Add(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.Intensity is float orig)
|
||||||
|
SetMember(light, "intensity", orig * Mod.Cfg.RecRoyaleNightSunScale);
|
||||||
|
if (sunColor is not null) SetMember(light, "color", sunColor);
|
||||||
|
if (sunRotation is not null && GetMember(light, "transform") is object t)
|
||||||
|
SetMember(t, "rotation", sunRotation);
|
||||||
|
touched.Add(ObjectName(light));
|
||||||
|
}
|
||||||
|
if (touched.Count > 0 && !_otherSunsLogged)
|
||||||
|
{
|
||||||
|
_otherSunsLogged = true;
|
||||||
|
Mod.Log.Msg($"[royale-night] matched {touched.Count} other directional light(s) to the mood sun: {string.Join(", ", touched)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] MatchOtherSunLights failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
}
|
||||||
|
private static bool _otherSunsLogged;
|
||||||
|
|
||||||
|
// Register the mood as the scene's defaultMood + sole availableMoods entry
|
||||||
|
// (get_CurrentMood returns availableMoods[index] when the synced index is
|
||||||
|
// set, else defaultMood — either way our mood). Originals are kept so
|
||||||
|
// UninstallSceneMood can put the scene back to "no room moods".
|
||||||
|
private static object? _sceneMoodsObj;
|
||||||
|
private static object? _origDefaultMood, _origAvailableMoods;
|
||||||
|
private static bool _sceneMoodInstalled;
|
||||||
|
|
||||||
|
private static void InstallSceneMood(object moods, object mood)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_sceneMoodInstalled && ReferenceEquals(_sceneMoodsObj, moods)) return;
|
||||||
|
_sceneMoodsObj = moods;
|
||||||
|
_origDefaultMood = GetMember(moods, "defaultMood");
|
||||||
|
_origAvailableMoods = GetMember(moods, "availableMoods");
|
||||||
|
|
||||||
|
var okDefault = SetMember(moods, "defaultMood", mood);
|
||||||
|
|
||||||
|
// availableMoods is an Il2CppReferenceArray<MoodSetting>; build a
|
||||||
|
// one-element array of the same interop type and fill slot 0.
|
||||||
|
var okArray = false;
|
||||||
|
var arrProp = moods.GetType().GetProperty("availableMoods", Any);
|
||||||
|
var arrType = arrProp?.PropertyType ?? _origAvailableMoods?.GetType();
|
||||||
|
if (arrType is not null)
|
||||||
|
{
|
||||||
|
object? arr = null;
|
||||||
|
foreach (var sig in new[] { new[] { typeof(long) }, new[] { typeof(int) } })
|
||||||
|
{
|
||||||
|
var ctor = arrType.GetConstructor(sig);
|
||||||
|
if (ctor is null) continue;
|
||||||
|
try { arr = ctor.Invoke(new object[] { sig[0] == typeof(long) ? 1L : 1 }); break; } catch { }
|
||||||
|
}
|
||||||
|
if (arr is not null)
|
||||||
|
{
|
||||||
|
var set = arrType.GetMethod("set_Item", new[] { typeof(int), _moodSettingType! });
|
||||||
|
if (set is null)
|
||||||
|
{
|
||||||
|
foreach (var m in arrType.GetMethods())
|
||||||
|
if (m.Name == "set_Item" && m.GetParameters().Length == 2 && m.GetParameters()[0].ParameterType == typeof(int)) { set = m; break; }
|
||||||
|
}
|
||||||
|
try { set?.Invoke(arr, new[] { 0, mood }); okArray = set is not null && SetMember(moods, "availableMoods", arr); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_sceneMoodInstalled = okDefault || okArray;
|
||||||
|
Mod.Log.Msg($"[royale-night] installed mood as scene default (defaultMood={okDefault} availableMoods={okArray})");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] InstallSceneMood failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UninstallSceneMood(object moods)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_sceneMoodInstalled) return;
|
||||||
|
if (_origDefaultMood is not null) SetMember(moods, "defaultMood", _origDefaultMood);
|
||||||
|
else SetMember(moods, "defaultMood", null!);
|
||||||
|
if (_origAvailableMoods is not null) SetMember(moods, "availableMoods", _origAvailableMoods);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_sceneMoodInstalled = false;
|
||||||
|
_sceneMoodsObj = null;
|
||||||
|
_origDefaultMood = _origAvailableMoods = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fog off / hide objects ────────────────────────────────────────
|
||||||
|
private static bool _fogStateSaved;
|
||||||
|
private static object? _origFogEnabled;
|
||||||
|
private static Type? _renderSettingsType;
|
||||||
|
|
||||||
|
private static void ForceFogOff()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_renderSettingsType ??= Mod.ResolveType("UnityEngine.RenderSettings");
|
||||||
|
if (_renderSettingsType is null) return;
|
||||||
|
var p = _renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static);
|
||||||
|
if (p is null) return;
|
||||||
|
if (!_fogStateSaved) { _origFogEnabled = p.GetValue(null); _fogStateSaved = true; }
|
||||||
|
if (p.GetValue(null) is true) p.SetValue(null, false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] ForceFogOff failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RestoreFogState()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_fogStateSaved && _renderSettingsType is not null && _origFogEnabled is bool b)
|
||||||
|
_renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static)?.SetValue(null, b);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_fogStateSaved = false;
|
||||||
|
_origFogEnabled = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable every Renderer whose GameObject or material name contains one
|
||||||
|
// of Cfg.RecRoyaleNightHideObjects. Runs each apply cycle but only scans
|
||||||
|
// once per scene (the matches are static scenery); ShowObjects re-enables.
|
||||||
|
private static readonly List<object> _hidden = new();
|
||||||
|
private static bool _hideScanned;
|
||||||
|
|
||||||
|
private static void HideObjects()
|
||||||
|
{
|
||||||
|
if (_hideScanned) return;
|
||||||
|
_hideScanned = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var patterns = new List<string>();
|
||||||
|
foreach (var s in Mod.Cfg.RecRoyaleNightHideObjects)
|
||||||
|
if (!string.IsNullOrWhiteSpace(s)) patterns.Add(s.Trim());
|
||||||
|
if (patterns.Count == 0) return;
|
||||||
|
|
||||||
|
var rendererType = Mod.ResolveType("UnityEngine.Renderer");
|
||||||
|
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||||
|
var il2cppRenderer = rendererType is null ? null : Il2CppTypeOf(rendererType);
|
||||||
|
if (rendererType is null || unityObject is null || il2cppRenderer is null) return;
|
||||||
|
|
||||||
|
MethodInfo? findAll = null;
|
||||||
|
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
|
||||||
|
if (findAll is null) return;
|
||||||
|
|
||||||
|
var arr = findAll.Invoke(null, new object[] { il2cppRenderer });
|
||||||
|
var n = Count(arr);
|
||||||
|
var names = new HashSet<string>();
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var item = Item(arr, i);
|
||||||
|
if (item is null) continue;
|
||||||
|
var r = AsType(item, rendererType);
|
||||||
|
if (r is null) continue;
|
||||||
|
var name = ObjectName(r);
|
||||||
|
var matName = "";
|
||||||
|
try { var mat = GetMember(r, "sharedMaterial"); if (mat is not null && !IsUnityNull(mat)) matName = ObjectName(mat); } catch { }
|
||||||
|
var hit = false;
|
||||||
|
foreach (var p in patterns)
|
||||||
|
if (name.Contains(p, StringComparison.OrdinalIgnoreCase) || matName.Contains(p, StringComparison.OrdinalIgnoreCase)) { hit = true; break; }
|
||||||
|
if (!hit) continue;
|
||||||
|
if (GetMember(r, "enabled") is true && SetMember(r, "enabled", false))
|
||||||
|
{
|
||||||
|
_hidden.Add(r);
|
||||||
|
names.Add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mod.Log.Msg($"[royale-night] hid {_hidden.Count} renderer(s) matching [{string.Join(",", patterns)}]: {string.Join(", ", names)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] HideObjects failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowObjects()
|
||||||
|
{
|
||||||
|
foreach (var r in _hidden)
|
||||||
|
{
|
||||||
|
try { if (!IsUnityNull(r)) SetMember(r, "enabled", true); } catch { }
|
||||||
|
}
|
||||||
|
_hidden.Clear();
|
||||||
|
_hideScanned = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CV2 room-settings probe ───────────────────────────────────────
|
||||||
|
// The Maker Pen mood chips ("Skydome Constant", "Room Skydome Modify",
|
||||||
|
// Sun/Fog/Background Objects equivalents) drive RoomSkydome / RoomSun /
|
||||||
|
// RoomFog components that hang off RoomElementSettingsManager, which
|
||||||
|
// CircuitsV2Manager brings up. Log whether any of that exists in this
|
||||||
|
// scene so we know what a chips-in-Rec-Royale build would have to add.
|
||||||
|
private static bool _probed;
|
||||||
|
private static void ProbeCircuitsSupport()
|
||||||
|
{
|
||||||
|
if (_probed) return;
|
||||||
|
_probed = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||||
|
if (unityObject is null) return;
|
||||||
|
MethodInfo? findAll = null;
|
||||||
|
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
|
||||||
|
if (findAll is null) return;
|
||||||
|
|
||||||
|
var parts = new List<string>();
|
||||||
|
foreach (var name in new[]
|
||||||
|
{
|
||||||
|
"RecRoom.CircuitsV2.CircuitsV2Manager",
|
||||||
|
"RecRoom.CircuitsV2.RoomSettings.RoomElementSettingsManager",
|
||||||
|
"RecRoom.CircuitsV2.RoomSettings.RoomSkydome",
|
||||||
|
"RecRoom.CircuitsV2.RoomSettings.RoomSun",
|
||||||
|
"RecRoom.CircuitsV2.RoomSettings.RoomFog",
|
||||||
|
"RecRoom.CircuitsV2.RoomSettings.RoomBackgroundObjects",
|
||||||
|
"MakerPen",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var t = Mod.ResolveType(name);
|
||||||
|
if (t is null) { parts.Add($"{name.Substring(name.LastIndexOf('.') + 1)}=<type missing>"); continue; }
|
||||||
|
var il2 = Il2CppTypeOf(t);
|
||||||
|
if (il2 is null) { parts.Add($"{t.Name}=?"); continue; }
|
||||||
|
int n = 0;
|
||||||
|
try { n = Count(findAll.Invoke(null, new object[] { il2 })); } catch (Exception ex) { parts.Add($"{t.Name}=err:{(ex.InnerException ?? ex).Message}"); continue; }
|
||||||
|
parts.Add($"{t.Name}={n}");
|
||||||
|
}
|
||||||
|
Mod.Log.Msg($"[royale-night] CV2 probe in '{_sceneName}': {string.Join(" ", parts)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] CV2 probe failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RestoreOtherSunLights()
|
||||||
|
{
|
||||||
|
foreach (var s in _otherSuns)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (IsUnityNull(s.Light)) continue;
|
||||||
|
if (s.Intensity is not null) SetMember(s.Light, "intensity", s.Intensity);
|
||||||
|
if (s.Color is not null) SetMember(s.Light, "color", s.Color);
|
||||||
|
if (s.ShadowStrength is not null) SetMember(s.Light, "shadowStrength", s.ShadowStrength);
|
||||||
|
if (s.Rotation is not null && GetMember(s.Light, "transform") is object t) SetMember(t, "rotation", s.Rotation);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
_otherSuns.Clear();
|
||||||
|
_otherSunsLogged = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure RenderSettings.skybox is a material on the "Custom/SkyBox"
|
||||||
|
// shader. The apply step assigns MoodConfig.SkyBoxMaterial
|
||||||
|
// (LNMDIIFOFPD.NHAEHKJINFH → SkyBoxMaterial); in RRO scenes that
|
||||||
|
// reference resolves to nothing, leaving the skybox null. Prefer any
|
||||||
|
// already-loaded material on that shader, else create one, and also
|
||||||
|
// write it into MoodConfig so later applies keep using it.
|
||||||
|
private static void EnsureMoodSkybox()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_renderSkybox is null || _materialType is null || _shaderType is null) return;
|
||||||
|
|
||||||
|
var current = _renderSkybox.GetValue(null);
|
||||||
|
var currentShader = current is null || IsUnityNull(current) ? "" : ShaderNameOf(current);
|
||||||
|
if (currentShader == "Custom/SkyBox") return;
|
||||||
|
|
||||||
|
object? mat = _ourSkybox is not null && !IsUnityNull(_ourSkybox) ? _ourSkybox : null;
|
||||||
|
|
||||||
|
// 1. Any loaded material already on the shader (e.g. the real
|
||||||
|
// Skybox_Moods_Sunny_Default_Mat if some scene brought it in).
|
||||||
|
if (mat is null)
|
||||||
|
{
|
||||||
|
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||||
|
var il2cppMat = Il2CppTypeOf(_materialType);
|
||||||
|
if (resources is not null && il2cppMat is not null)
|
||||||
|
{
|
||||||
|
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue;
|
||||||
|
var arr = m.Invoke(null, new object[] { il2cppMat });
|
||||||
|
var n = Count(arr);
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var item = Item(arr, i);
|
||||||
|
if (item is null) continue;
|
||||||
|
var wrapped = AsType(item, _materialType);
|
||||||
|
if (wrapped is not null && ShaderNameOf(wrapped) == "Custom/SkyBox") { mat = wrapped; break; }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mat is not null) Mod.Log.Msg($"[royale-night] using loaded skybox material '{ObjectName(mat)}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build one: new Material(Shader.Find("Custom/SkyBox")).
|
||||||
|
if (mat is null)
|
||||||
|
{
|
||||||
|
var find = AccessTools.Method(_shaderType, "Find", new[] { typeof(string) });
|
||||||
|
var shader = find?.Invoke(null, new object[] { "Custom/SkyBox" });
|
||||||
|
if (shader is null || IsUnityNull(shader))
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[royale-night] Shader.Find(\"Custom/SkyBox\") returned nothing — sky colours will not apply");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var ctor = _materialType.GetConstructor(new[] { _shaderType }) ?? _materialType.GetConstructor(new[] { shader.GetType() });
|
||||||
|
if (ctor is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[royale-night] Material(Shader) constructor not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mat = ctor.Invoke(new[] { shader });
|
||||||
|
SetMember(mat, "name", "DorkNet_RecRoyaleNight_Skybox");
|
||||||
|
Mod.Log.Msg("[royale-night] created skybox material on Custom/SkyBox");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ourSkybox = mat;
|
||||||
|
_renderSkybox.SetValue(null, mat);
|
||||||
|
|
||||||
|
// Keep the engine's own apply path pointing at it too.
|
||||||
|
if (_moodConfigHolder is not null)
|
||||||
|
{
|
||||||
|
foreach (var p in _moodConfigHolder.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (!p.CanRead || p.GetIndexParameters().Length != 0) continue;
|
||||||
|
var cfg = p.GetValue(null);
|
||||||
|
if (cfg is null || IsUnityNull(cfg)) continue;
|
||||||
|
if (SetMember(cfg, "SkyBoxMaterial", mat)) Mod.Log.Msg("[royale-night] MoodConfig.SkyBoxMaterial ← our material");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureMoodSkybox failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ShaderNameOf(object material)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var shader = GetMember(material, "shader");
|
||||||
|
return shader is null || IsUnityNull(shader) ? "" : ObjectName(shader);
|
||||||
|
}
|
||||||
|
catch { return ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeSkybox()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sky = _renderSkybox?.GetValue(null);
|
||||||
|
if (sky is null || IsUnityNull(sky)) return "<null>";
|
||||||
|
return $"'{ObjectName(sky)}' [{ShaderNameOf(sky)}]";
|
||||||
|
}
|
||||||
|
catch { return "?"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-exposure lives in the URP Volume the mood engine drives. It only
|
||||||
|
// shows if the Volume is enabled with weight and the camera renders
|
||||||
|
// post-processing (Rec Room's graphics settings can turn that off).
|
||||||
|
private static void EnsurePostProcessing(object moods)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var volume = Read(_volume, moods);
|
||||||
|
if (volume is not null && !IsUnityNull(volume))
|
||||||
|
{
|
||||||
|
var enabled = GetMember(volume, "enabled");
|
||||||
|
var weight = GetMember(volume, "weight");
|
||||||
|
var isGlobal = GetMember(volume, "isGlobal");
|
||||||
|
var profile = GetMember(volume, "profile");
|
||||||
|
Mod.Log.Msg($"[royale-night] volume: enabled={enabled} weight={weight} global={isGlobal} profile={(profile is null ? "<null>" : ObjectName(profile))}");
|
||||||
|
if (Mod.Cfg.RecRoyaleNightForcePostProcessing)
|
||||||
|
{
|
||||||
|
if (enabled is false) SetMember(volume, "enabled", true);
|
||||||
|
if (weight is float w && w < 1f) SetMember(volume, "weight", 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else Mod.Log.Warning("[royale-night] scene mood Volume is null — post-exposure darkening unavailable");
|
||||||
|
|
||||||
|
if (_cameraDataType is null) { Mod.Log.Warning("[royale-night] UniversalAdditionalCameraData type not found"); return; }
|
||||||
|
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||||
|
var il2cppCam = Il2CppTypeOf(_cameraDataType);
|
||||||
|
if (unityObject is null || il2cppCam is null) return;
|
||||||
|
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue;
|
||||||
|
var arr = m.Invoke(null, new object[] { il2cppCam });
|
||||||
|
var n = Count(arr);
|
||||||
|
int forced = 0;
|
||||||
|
var states = new List<string>();
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var item = Item(arr, i);
|
||||||
|
if (item is null) continue;
|
||||||
|
var cam = AsType(item, _cameraDataType);
|
||||||
|
if (cam is null) continue;
|
||||||
|
var pp = GetMember(cam, "renderPostProcessing");
|
||||||
|
states.Add($"{ObjectName(cam)}={pp}");
|
||||||
|
if (pp is false && Mod.Cfg.RecRoyaleNightForcePostProcessing && SetMember(cam, "renderPostProcessing", true)) forced++;
|
||||||
|
}
|
||||||
|
Mod.Log.Msg($"[royale-night] camera post-processing: {string.Join(", ", states)}{(forced > 0 ? $" → forced on ({forced})" : "")}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsurePostProcessing failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Darken the mood data in place before it is applied:
|
||||||
|
// ColorGradingSettings.exposure += RecRoyaleNightExposure (EV; the
|
||||||
|
// ColorGrading blender writes it into ColorAdjustments.postExposure
|
||||||
|
// via VolumeParameter.Override — the only knob that also dims baked
|
||||||
|
// lightmaps, since it's a full-frame post effect)
|
||||||
|
// SunSettings.sunIntensity *= RecRoyaleNightSunScale
|
||||||
|
// SkyBoxSettings.ambientLightIntensity *= RecRoyaleNightAmbientScale
|
||||||
|
// Originals are kept and restored by RestoreDarkness so the shared asset
|
||||||
|
// is clean again for any custom room that uses the same mood.
|
||||||
|
private static void PatchDarkness(object mood)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_darkPatchedMood, mood)) return;
|
||||||
|
RestoreDarkness();
|
||||||
|
|
||||||
|
var grading = GetMember(mood, "ColorGradingSettings");
|
||||||
|
var sun = GetMember(mood, "SunSettings");
|
||||||
|
var sky = GetMember(mood, "SkyBoxSettings");
|
||||||
|
|
||||||
|
if (grading is not null && Mod.Cfg.RecRoyaleNightExposure != 0f && GetMember(grading, "exposure") is float ev)
|
||||||
|
{
|
||||||
|
_origExposure = ev;
|
||||||
|
SetMember(grading, "exposure", ev + Mod.Cfg.RecRoyaleNightExposure);
|
||||||
|
}
|
||||||
|
if (sun is not null && Mod.Cfg.RecRoyaleNightSunScale != 1f && GetMember(sun, "sunIntensity") is float si)
|
||||||
|
{
|
||||||
|
_origSunIntensity = si;
|
||||||
|
SetMember(sun, "sunIntensity", si * Mod.Cfg.RecRoyaleNightSunScale);
|
||||||
|
}
|
||||||
|
if (sky is not null && Mod.Cfg.RecRoyaleNightAmbientScale != 1f && GetMember(sky, "ambientLightIntensity") is float ai)
|
||||||
|
{
|
||||||
|
_origAmbient = ai;
|
||||||
|
SetMember(sky, "ambientLightIntensity", ai * Mod.Cfg.RecRoyaleNightAmbientScale);
|
||||||
|
}
|
||||||
|
_darkPatchedMood = mood;
|
||||||
|
Mod.Log.Msg($"[royale-night] darkened mood: exposure {_origExposure?.ToString("0.##") ?? "?"}→{(_origExposure + Mod.Cfg.RecRoyaleNightExposure)?.ToString("0.##") ?? "?"} EV, " +
|
||||||
|
$"sun ×{Mod.Cfg.RecRoyaleNightSunScale} ({_origSunIntensity?.ToString("0.##") ?? "?"}), ambient ×{Mod.Cfg.RecRoyaleNightAmbientScale} ({_origAmbient?.ToString("0.##") ?? "?"})");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] PatchDarkness failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RestoreDarkness()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_darkPatchedMood is not null)
|
||||||
|
{
|
||||||
|
var grading = GetMember(_darkPatchedMood, "ColorGradingSettings");
|
||||||
|
var sun = GetMember(_darkPatchedMood, "SunSettings");
|
||||||
|
var sky = GetMember(_darkPatchedMood, "SkyBoxSettings");
|
||||||
|
if (grading is not null && _origExposure is float e) SetMember(grading, "exposure", e);
|
||||||
|
if (sun is not null && _origSunIntensity is float s) SetMember(sun, "sunIntensity", s);
|
||||||
|
if (sky is not null && _origAmbient is float a) SetMember(sky, "ambientLightIntensity", a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_darkPatchedMood = null;
|
||||||
|
_origExposure = _origSunIntensity = _origAmbient = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SetMember(object o, string name, object value)
|
||||||
|
{
|
||||||
|
var t = o.GetType();
|
||||||
|
var p = t.GetProperty(name, Any);
|
||||||
|
if (p is not null && p.CanWrite) { try { p.SetValue(o, value); return true; } catch { } }
|
||||||
|
var f = t.GetField(name, Any);
|
||||||
|
if (f is not null) { try { f.SetValue(o, value); return true; } catch { } }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Il2CppInterop exposes il2cpp fields as properties (and, on some
|
||||||
|
// versions, as fields). Read either.
|
||||||
|
private static object? GetMember(object o, string name)
|
||||||
|
{
|
||||||
|
var t = o.GetType();
|
||||||
|
var p = t.GetProperty(name, Any);
|
||||||
|
if (p is not null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||||
|
{ try { return p.GetValue(o); } catch { } }
|
||||||
|
var f = t.GetField(name, Any);
|
||||||
|
if (f is not null) { try { return f.GetValue(o); } catch { } }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Il2CppType.From(Type) → Il2CppSystem.Type, via reflection so this
|
||||||
|
// project keeps not referencing Il2Cppmscorlib at compile time.
|
||||||
|
private static object? Il2CppTypeOf(Type type)
|
||||||
|
{
|
||||||
|
foreach (var m in typeof(Il2CppType).GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (m.Name != "From" || m.IsGenericMethodDefinition) continue;
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
if (ps.Length >= 1 && ps[0].ParameterType == typeof(Type))
|
||||||
|
{
|
||||||
|
var args = new object?[ps.Length];
|
||||||
|
args[0] = type;
|
||||||
|
for (int i = 1; i < ps.Length; i++) args[i] = ps[i].HasDefaultValue ? ps[i].DefaultValue : (ps[i].ParameterType.IsValueType ? Activator.CreateInstance(ps[i].ParameterType) : null);
|
||||||
|
var r = m.Invoke(null, args);
|
||||||
|
if (r is not null) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? GetSceneManager()
|
||||||
|
{
|
||||||
|
var inst = _sceneManagerInstance?.GetValue(null);
|
||||||
|
if (inst is null || IsUnityNull(inst)) return null;
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the configured MoodSetting asset. Resources.Load(path, Type)
|
||||||
|
// pulls it out of resources.assets by name (the ResourceManager table
|
||||||
|
// keys are the lower-cased asset names, no folder). Fallback:
|
||||||
|
// Resources.FindObjectsOfTypeAll(MoodSetting) by name, which only sees
|
||||||
|
// assets already in memory.
|
||||||
|
private static object? GetNightMood()
|
||||||
|
{
|
||||||
|
var wanted = (Mod.Cfg.RecRoyaleNightMood ?? "").Trim();
|
||||||
|
if (wanted.Length == 0) wanted = "Night_Calm_Outdoor_Mood";
|
||||||
|
if (_nightMood is not null && _nightMoodLoadedName == wanted && !IsUnityNull(_nightMood))
|
||||||
|
return _nightMood;
|
||||||
|
|
||||||
|
_nightMood = null;
|
||||||
|
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||||
|
if (resources is null || _moodSettingType is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[royale-night] UnityEngine.Resources not found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var il2cppType = Il2CppTypeOf(_moodSettingType);
|
||||||
|
if (il2cppType is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[royale-night] Il2CppType.From(Type) not found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
object? found = null;
|
||||||
|
|
||||||
|
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (m.Name != "Load" || m.IsGenericMethodDefinition) continue;
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
if (ps.Length != 2 || ps[0].ParameterType != typeof(string)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
found = m.Invoke(null, new object[] { wanted, il2cppType });
|
||||||
|
if (found is not null && !IsUnityNull(found)) break;
|
||||||
|
found = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] Resources.Load failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found is null)
|
||||||
|
{
|
||||||
|
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition) continue;
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
if (ps.Length != 1) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var arr = m.Invoke(null, new object[] { il2cppType });
|
||||||
|
var n = Count(arr);
|
||||||
|
var names = new List<string>();
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var item = Item(arr, i);
|
||||||
|
if (item is null) continue;
|
||||||
|
var name = ObjectName(item);
|
||||||
|
names.Add(name);
|
||||||
|
if (string.Equals(name, wanted, StringComparison.OrdinalIgnoreCase)) { found = item; break; }
|
||||||
|
}
|
||||||
|
if (found is null)
|
||||||
|
Mod.Log.Warning($"[royale-night] mood '{wanted}' not in memory; loaded moods: {string.Join(", ", names)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[royale-night] FindObjectsOfTypeAll failed: {(ex.InnerException ?? ex).Message}"); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[royale-night] could not load MoodSetting '{wanted}'");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resources.Load hands back a UnityEngine.Object wrapper; re-wrap the
|
||||||
|
// same il2cpp pointer as MoodSetting so the apply method accepts it.
|
||||||
|
_nightMood = AsType(found, _moodSettingType);
|
||||||
|
_nightMoodLoadedName = wanted;
|
||||||
|
return _nightMood;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? AsType(object obj, Type target)
|
||||||
|
{
|
||||||
|
if (target.IsInstanceOfType(obj)) return obj;
|
||||||
|
if (obj is Il2CppObjectBase il2cpp)
|
||||||
|
{
|
||||||
|
var ctor = target.GetConstructor(new[] { typeof(IntPtr) });
|
||||||
|
if (ctor is not null) return ctor.Invoke(new object[] { il2cpp.Pointer });
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── hotkey ────────────────────────────────────────────────────────
|
||||||
|
private static void PollHotkey()
|
||||||
|
{
|
||||||
|
var key = Mod.Cfg.RecRoyaleNightToggleKey;
|
||||||
|
if (string.IsNullOrWhiteSpace(key)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_inputResolved) ResolveInput(key);
|
||||||
|
if (_getKeyDown is null || _toggleKey is null) return;
|
||||||
|
if (_getKeyDown.Invoke(null, new[] { _toggleKey }) is true) Toggle();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[royale-night] hotkey poll failed: {ex.Message}");
|
||||||
|
_getKeyDown = null; // stop spamming
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ResolveInput(string key)
|
||||||
|
{
|
||||||
|
_inputResolved = true;
|
||||||
|
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
|
||||||
|
var inputType = Mod.ResolveType("UnityEngine.Input");
|
||||||
|
if (keyCodeType is null || inputType is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[royale-night] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { _toggleKey = Enum.Parse(keyCodeType, key, ignoreCase: true); }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[royale-night] '{key}' is not a UnityEngine.KeyCode — hotkey disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||||
|
if (_getKeyDown is null) Mod.Log.Warning("[royale-night] Input.GetKeyDown(KeyCode) not found — hotkey disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────
|
||||||
|
private static bool MatchesScene(string sceneName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(sceneName)) return false;
|
||||||
|
foreach (var s in Mod.Cfg.RecRoyaleNightScenes)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) continue;
|
||||||
|
if (sceneName.Contains(s.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnityEngine.Object wrappers are non-null C# objects even when the
|
||||||
|
// native object is destroyed/missing; `== null` is overloaded in the
|
||||||
|
// interop, so route through the static op_Equality when present.
|
||||||
|
private static bool IsUnityNull(object obj)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var t = obj.GetType();
|
||||||
|
var eq = t.GetMethod("op_Equality", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
|
||||||
|
if (eq is not null && eq.GetParameters().Length == 2)
|
||||||
|
return eq.Invoke(null, new[] { obj, null }) is true;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ObjectName(object obj)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var p = obj.GetType().GetProperty("name", Any);
|
||||||
|
return p?.GetValue(obj) as string ?? "";
|
||||||
|
}
|
||||||
|
catch { return ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Describe(object mood)
|
||||||
|
{
|
||||||
|
var friendly = "";
|
||||||
|
try { friendly = _friendlyName?.GetValue(mood) as string ?? ""; } catch { }
|
||||||
|
var name = ObjectName(mood);
|
||||||
|
return friendly.Length > 0 ? $"{friendly} ({name})" : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Count(object? arr)
|
||||||
|
{
|
||||||
|
if (arr is null) return 0;
|
||||||
|
if (arr is Array a) return a.Length;
|
||||||
|
var p = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||||
|
return (p?.GetValue(arr) as int?) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? Item(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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"ServerHost": "{HOST}",
|
"ServerHost": "{HOST}",
|
||||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||||
|
"AutoCreateAccount": false,
|
||||||
"EnableDebugConsole": false,
|
"EnableDebugConsole": false,
|
||||||
"DebugConsoleToggleKey": "BackQuote",
|
"DebugConsoleToggleKey": "BackQuote",
|
||||||
"DevCommands": [],
|
"DevCommands": [],
|
||||||
@@ -11,5 +12,18 @@
|
|||||||
"GoldenTrophy",
|
"GoldenTrophy",
|
||||||
"IsleOfLostSkulls",
|
"IsleOfLostSkulls",
|
||||||
"TheRiseofJumbotron"
|
"TheRiseofJumbotron"
|
||||||
]
|
],
|
||||||
|
"RecRoyaleNightMode": false,
|
||||||
|
"RecRoyaleNightMood": "Night_Calm_Outdoor_Mood",
|
||||||
|
"RecRoyaleNightScenes": ["RecRoyale"],
|
||||||
|
"RecRoyaleNightFlashlights": true,
|
||||||
|
"RecRoyaleNightFog": false,
|
||||||
|
"RecRoyaleNightExposure": -3.5,
|
||||||
|
"RecRoyaleNightSunScale": 0.15,
|
||||||
|
"RecRoyaleNightAmbientScale": 0.2,
|
||||||
|
"RecRoyaleNightForcePostProcessing": true,
|
||||||
|
"RecRoyaleNightMatchSunLights": true,
|
||||||
|
"RecRoyaleNightDisableFog": false,
|
||||||
|
"RecRoyaleNightHideObjects": ["Cloud"],
|
||||||
|
"RecRoyaleNightToggleKey": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -88,6 +88,36 @@ public class RecNetAccount
|
|||||||
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
|
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
|
||||||
[JsonPropertyName("createdAt")]
|
[JsonPropertyName("createdAt")]
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// 2023-03-21 account reader (RecNet.Runtime CICAKEDCJLA / MJKDDBKGNOP /
|
||||||
|
// AEGCKMKICFG) registers these keys in three casings each, e.g.
|
||||||
|
// "DisplayEmoji"/"displayEmoji"/"displayemoji" (CICAKEDCJLA.txt:1398-1550).
|
||||||
|
// Missing keys default on the client, which is why profile emoji,
|
||||||
|
// banner, pronouns and identity flags never rendered for anyone.
|
||||||
|
private string displayEmoji = string.Empty;
|
||||||
|
private string bannerImage = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("displayEmoji")]
|
||||||
|
public string DisplayEmoji
|
||||||
|
{
|
||||||
|
get => displayEmoji;
|
||||||
|
set => displayEmoji = value ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonPropertyName("bannerImage")]
|
||||||
|
public string BannerImage
|
||||||
|
{
|
||||||
|
get => bannerImage;
|
||||||
|
set => bannerImage = value ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pronoun flags enum, serialized as its Int32 value.</summary>
|
||||||
|
[JsonPropertyName("personalPronouns")]
|
||||||
|
public int PersonalPronouns { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Identity flags enum, serialized as its Int32 value.</summary>
|
||||||
|
[JsonPropertyName("identityFlags")]
|
||||||
|
public int IdentityFlags { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -112,6 +142,13 @@ public class RecNetSelfAccount : RecNetAccount
|
|||||||
|
|
||||||
[JsonPropertyName("parentAccountId")]
|
[JsonPropertyName("parentAccountId")]
|
||||||
public int? ParentAccountId { get; set; } = null;
|
public int? ParentAccountId { get; set; } = null;
|
||||||
|
|
||||||
|
// 2023 self-account reader key (CICAKEDCJLA.txt:1310). An absent key
|
||||||
|
// defaults to 0, which the settings UI renders as "no username changes
|
||||||
|
// left". DorkNet does not rate-limit username changes, so report a
|
||||||
|
// generous allowance.
|
||||||
|
[JsonPropertyName("availableUsernameChanges")]
|
||||||
|
public int AvailableUsernameChanges { get; set; } = 99;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
*.cmd text eol=crlf
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Operator secrets and build outputs — never commit.
|
||||||
|
dorknet-quest.cfg
|
||||||
|
*.keystore
|
||||||
|
*.so
|
||||||
|
build/
|
||||||
|
*_generated.h
|
||||||
|
|
||||||
|
# The resolved per-build target tables ARE committed (targets/<ver>-<code>.json);
|
||||||
|
# only the generated C header is ignored.
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# DorkNet.Quest
|
||||||
|
|
||||||
|
Sideload-patches the **Meta Quest** build of Rec Room **2023.03.21**
|
||||||
|
(versionName `20230317`, versionCode `2128210`) to connect to a DorkNet server.
|
||||||
|
|
||||||
|
It is the Quest counterpart of `DorkNet.ClientMod` (the PC MelonLoader mod) plus
|
||||||
|
`tools/patch-client.ps1` (the PC Photon byte-patch). Android/IL2CPP has no
|
||||||
|
MelonLoader, so everything is done in **one injected native library**
|
||||||
|
(`libdorknet.so`): it is added as a `DT_NEEDED` entry on `libmain.so` so the
|
||||||
|
loader maps it before Unity, then it installs arm64 inline hooks at addresses
|
||||||
|
resolved from the build's own `dump.cs`.
|
||||||
|
|
||||||
|
This build reports appVersion **`20230317`** — the *same* version the PC client
|
||||||
|
reports — so a Quest player patched with this tool shares rooms, presence and
|
||||||
|
Photon shards with PC DorkNet players with no server-side version change.
|
||||||
|
|
||||||
|
> This ships **no** Rec Room code or assets. You supply your own extracted store
|
||||||
|
> APK and OBB. It disables the client's certificate pinning and image-signature
|
||||||
|
> checks so a self-hosted server works; do not redistribute game binaries.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
| Hook | Effect | PC equivalent |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `HTTPManager.SendRequest` | rewrite `<svc>.rec.net` → `<svc>.<ServerHost>`, or into a `{service}` template such as `{service}-dev.example.com` for hyphen-style hosts (the name server supplies every other URL) | `ClientMod` UriPatches |
|
||||||
|
| Photon `AppSettings` factory | inject the operator's Realtime/Voice/Chat app ids and fixed region (`PhotonRegion`, must match the PC clients) | `tools/patch-client.ps1` |
|
||||||
|
| `PlatformSettings.get_AppID` / `get_MobileAppID` | return the operator's Meta app id (only when set) | — |
|
||||||
|
| `UnityTlsContext.VerifyCallback` + BouncyCastle `NotifyServerCertificate` | accept the self-hosted TLS chain | `ClientMod` TlsPatches |
|
||||||
|
| three certificate-pin verifiers | accept the chain (pins never match a self-hosted host) | `ClientMod` DisableTLSPinning |
|
||||||
|
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
|
||||||
|
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
|
||||||
|
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
|
||||||
|
| Oculus `ovr_*` platform calls | (no-Meta build only) pass the entitlement / user-proof / logged-in-user requests Meta refuses for the renamed app (including the SDK's second check on the inner native message), answering the logged-in user with the placeholder user id `1` | — |
|
||||||
|
| title-screen auto-login (`TitleScreenFlowModel`) | a headset with no remembered account opens the **sign-in page** instead of auto-creating an account (`AutoCreateAccount = true` restores Rec Room's behaviour) | — |
|
||||||
|
|
||||||
|
### Two builds, selected by `OculusAppId`
|
||||||
|
|
||||||
|
- **Set** — the *Meta build*: Meta's real entitlement + login run against the
|
||||||
|
app you registered; the platform hooks stay disarmed. Players need a Meta
|
||||||
|
account, and your package + signing cert must be registered with that app.
|
||||||
|
- **Blank** — the *no-Meta-account build*: the platform path is steered past
|
||||||
|
Meta's checks to Rec Room's own username/password screen. It authenticates
|
||||||
|
nothing — every headset sends the placeholder platform user id `1`. DorkNet's
|
||||||
|
server treats platform id `1` (and `0`/blank) as a non-identity and keys
|
||||||
|
accounts by `deviceId` instead (`PlayerService.IsPlaceholderPlatformId`), so
|
||||||
|
players are **not** merged into one account and are never offered each other's
|
||||||
|
"remembered logins". **Never** add a server rule that accepts platform id `1`
|
||||||
|
as an identity.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Android NDK** r27c-class (`ANDROID_NDK`, or `ndk/<ver>` under
|
||||||
|
`ANDROID_SDK_ROOT`) — compiles the hook library against bionic.
|
||||||
|
- **Android SDK build-tools** (`zipalign`, `apksigner`) — `ANDROID_SDK_ROOT`.
|
||||||
|
- **apktool** `apktool.jar` (`APKTOOL_JAR`, or next to `ANDROID_SDK_ROOT`).
|
||||||
|
- **JDK** (`keytool` for the debug keystore; apksigner needs a JRE).
|
||||||
|
- **Python 3** with **LIEF** (`pip install lief`) — the `DT_NEEDED` injection.
|
||||||
|
- **[Il2CppDumper](https://github.com/Perfare/Il2CppDumper)** — to produce
|
||||||
|
`dump.cs` for the target APK (see below).
|
||||||
|
- Your own **store APK** (`com.AgainstGravity.RecRoom`, versionCode `2128210`)
|
||||||
|
and its matching **OBB**.
|
||||||
|
|
||||||
|
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
|
||||||
|
> can't write under `Documents`. `build.py` builds in `%TEMP%` by default;
|
||||||
|
> keep the work dir outside protected folders.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. Dump the target build (Il2CppDumper), once, into a scratch dir:
|
||||||
|
dotnet Il2CppDumper.dll `
|
||||||
|
<extracted>\lib\arm64-v8a\libil2cpp.so `
|
||||||
|
<extracted>\assets\bin\Data\Managed\Metadata\global-metadata.dat `
|
||||||
|
<scratch>\dump
|
||||||
|
|
||||||
|
# 2. Configure:
|
||||||
|
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
|
||||||
|
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
|
||||||
|
|
||||||
|
# 3. Build the signed APK (and, with --obb, the release package):
|
||||||
|
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
|
||||||
|
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
|
||||||
|
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
|
||||||
|
py -3 DorkNet.Quest\scripts\build.py build `
|
||||||
|
--apk C:\path\to\RecRoom-store-2128210.apk `
|
||||||
|
--dump-cs <scratch>\dump\dump.cs `
|
||||||
|
--obb C:\path\to\main.2128210.com.AgainstGravity.RecRoom.obb `
|
||||||
|
--keystore C:\path\to\dorknet-debug.keystore
|
||||||
|
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
|
||||||
|
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with --obb)
|
||||||
|
```
|
||||||
|
|
||||||
|
The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`).
|
||||||
|
|
||||||
|
For a build whose target table is already committed under `targets/`, pass
|
||||||
|
`--targets-json DorkNet.Quest/targets/20230317-2128210.json` instead of
|
||||||
|
`--dump-cs`. No Il2CppDumper is needed then: the build checks the table
|
||||||
|
against the APK's `libil2cpp.so` and stops on a mismatch. This is what the
|
||||||
|
Jenkins job does (`ci/jenkins/README.md`).
|
||||||
|
|
||||||
|
With `--obb`, the build also writes a release folder ready to hand to players:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `DorkNet-20230317.xapk` | standard XAPK: `manifest.json`, the APK, the OBB at its install path, `icon.png` |
|
||||||
|
| `install.cmd` / `install.ps1` | one-command install on Windows (double-click `install.cmd`) |
|
||||||
|
| `install.sh` | one-command install on macOS / Linux |
|
||||||
|
| `README.txt` | player instructions |
|
||||||
|
|
||||||
|
To package an APK you already built, run `build.py package --apk <apk> --obb <obb>`.
|
||||||
|
The package contains game binaries from your store copy, so only share it with
|
||||||
|
people who own the game.
|
||||||
|
|
||||||
|
`build.py build` chains: resolve targets → generate headers → compile
|
||||||
|
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
|
||||||
|
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
|
||||||
|
zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug
|
||||||
|
keystore in the work dir on first run. **Keep that keystore** and pass it with
|
||||||
|
`--keystore` from then on — Meta ties tokens to app id + package
|
||||||
|
+ signing cert, so re-signing with a different key breaks any Meta registration.
|
||||||
|
It is never committed.
|
||||||
|
|
||||||
|
### Rebasing onto a different build
|
||||||
|
|
||||||
|
Obfuscated Assembly-CSharp names are re-randomised each release but shared with
|
||||||
|
the PC build of the same release, so the PC decompile is the oracle. For a new
|
||||||
|
build:
|
||||||
|
|
||||||
|
1. Dump it with Il2CppDumper.
|
||||||
|
2. Re-run `scripts/resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so
|
||||||
|
--version-name <v> --version-code <c> --objdump <ndk>/llvm-objdump` and
|
||||||
|
confirm every target resolves, is ≥ 20 bytes, and has a real frame-setup
|
||||||
|
prologue. Adjust `targets/spec.json` signatures if a name/shape changed
|
||||||
|
(cross-check against the PC decompile first).
|
||||||
|
3. Commit the new `targets/<v>-<c>.json`.
|
||||||
|
|
||||||
|
The library refuses to arm any target whose 20-byte fingerprint does not match
|
||||||
|
the running `libil2cpp.so`, so a table can never be applied to the wrong build.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
**From the browser (easiest).** Open the **Quest** page of your DorkNet site
|
||||||
|
(`https://<apex>/quest`) in Chrome or Edge on a computer, plug the headset in
|
||||||
|
over USB, pick the `.xapk`, click **Connect**, accept "Allow USB debugging" in
|
||||||
|
the headset, then **Install**. The page talks to the headset over WebUSB
|
||||||
|
(Tango, `@yume-chan/adb`): it installs the APK, copies the OBB to its install
|
||||||
|
path (skipped if an identical one is already there) and launches the app. The
|
||||||
|
`.xapk` never leaves the player's computer; the server hosts no game files.
|
||||||
|
Close SideQuest / Meta Quest Developer Hub, or run `adb kill-server`, first:
|
||||||
|
only one program can hold the headset's USB connection. Firefox, Safari and
|
||||||
|
phones don't support WebUSB.
|
||||||
|
|
||||||
|
**From a release folder**, one command does everything (finds adb and the headset,
|
||||||
|
installs the APK, copies the OBB to the right path, skips the OBB if it is
|
||||||
|
already there, and launches the app):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\install.cmd # Windows (or double-click it)
|
||||||
|
./install.sh # macOS / Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
Both take an optional package path, `-Serial`/`--serial` for a specific headset
|
||||||
|
and `-NoLaunch`/`--no-launch`. SideQuest and other XAPK installers can open the
|
||||||
|
`.xapk` directly. The app shows up as `AppName` (default **DorkNet**) under
|
||||||
|
Library > Unknown Sources.
|
||||||
|
|
||||||
|
By hand:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PKG=net.dorknet.RecRoom # your PackageName
|
||||||
|
VC=2128210
|
||||||
|
adb uninstall $PKG 2>/dev/null
|
||||||
|
adb install -r dorknet-recroom-20230317.apk
|
||||||
|
adb shell mkdir -p /sdcard/Android/obb/$PKG
|
||||||
|
adb push main.2128210.com.AgainstGravity.RecRoom.obb \
|
||||||
|
/sdcard/Android/obb/$PKG/main.$VC.$PKG.obb
|
||||||
|
```
|
||||||
|
|
||||||
|
The on-device OBB filename **must** be `main.<versionCode>.<package>.obb` —
|
||||||
|
Unity rebuilds that exact path at runtime, so the source file's own name is
|
||||||
|
irrelevant, only the destination is.
|
||||||
|
|
||||||
|
## Check it on the headset
|
||||||
|
|
||||||
|
**Wear the headset** — off-head it sleeps within seconds and never finishes
|
||||||
|
init, which looks like a crash.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb logcat -c
|
||||||
|
adb shell am start -n net.dorknet.RecRoom/com.unity3d.player.UnityPlayerActivity
|
||||||
|
adb logcat -d -s DORKNET
|
||||||
|
```
|
||||||
|
|
||||||
|
A healthy boot logs: `DorkNet.Quest hooks loaded`, `init: build 20230317/...`,
|
||||||
|
every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<host>/`,
|
||||||
|
`unitytls verify: forcing 0x8 -> SUCCESS`, `[http<-] 200 ...`, and
|
||||||
|
`photon appsettings: RT=... Voice=... Chat=...`.
|
||||||
|
|
||||||
|
| Symptom | Likely cause |
|
||||||
|
| --- | --- |
|
||||||
|
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
|
||||||
|
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.py`, don't hand-zip |
|
||||||
|
| boots to an empty world | OBB missing or named for the wrong versionCode |
|
||||||
|
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
|
||||||
|
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
|
||||||
|
| multiplayer never connects | Photon app ids not set in the cfg |
|
||||||
|
| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` |
|
||||||
|
| "Unable to load Oculus user account" | old build (before the GetLoggedInUser bypass), or a Meta build whose app id the account isn't entitled to |
|
||||||
|
| game restarts while "creating a new account" | `AutoCreateAccount = true` on a server with sign-ups off — set it to `false` so the sign-in page opens |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/dorknet_quest.cpp` | all hooks + init |
|
||||||
|
| `src/config.h` / `config.cpp` | compile-time defaults + runtime cfg loader |
|
||||||
|
| `src/third_party/And64InlineHook/` | vendored arm64 inline hooker (MIT, © Rprop) |
|
||||||
|
| `targets/spec.json` | target signatures (class header + method), build-independent |
|
||||||
|
| `targets/<ver>-<code>.json` | resolved RVAs + fingerprints for one build |
|
||||||
|
| `scripts/resolve_targets.py` | dump.cs + libil2cpp.so → target table (+ prologue check) |
|
||||||
|
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
|
||||||
|
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
|
||||||
|
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
|
||||||
|
| `scripts/build.py` | `build`: store APK → signed patched APK (+ release package with `--obb`); `package`: patched APK + OBB → `.xapk` + installers |
|
||||||
|
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
|
||||||
|
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
- [RecFlare](https://github.com/recflare) by djdevin. Their Quest client
|
||||||
|
showed the way this patcher is built: one native hook library loaded
|
||||||
|
through a `DT_NEEDED` entry on `libmain.so`, and which client checks need
|
||||||
|
hooking. They target a newer build (20230414), so every hook target here
|
||||||
|
was re-derived from the 20230317 build; no RecFlare code is included.
|
||||||
|
- [And64InlineHook](https://github.com/Rprop/And64InlineHook) © Rprop (MIT) —
|
||||||
|
vendored under `src/third_party/`.
|
||||||
|
- [Il2CppDumper](https://github.com/Perfare/Il2CppDumper), [LIEF](https://lief.re).
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# DorkNet.Quest build configuration.
|
||||||
|
#
|
||||||
|
# Copy to dorknet-quest.cfg (git-ignored) and edit. scripts/build.ps1 bakes
|
||||||
|
# these into libdorknet.so at compile time. Omit a key to keep the built-in
|
||||||
|
# default. This same file can be pushed to
|
||||||
|
# /sdcard/Android/data/<PackageName>/files/dorknet-quest.cfg
|
||||||
|
# to override on-device without a rebuild.
|
||||||
|
|
||||||
|
# ---- deployment ----
|
||||||
|
|
||||||
|
# The apex your DorkNet server is reachable at. The client's service hosts are
|
||||||
|
# rewritten onto it: api.rec.net -> api.<ServerHost>, auth.rec.net ->
|
||||||
|
# auth.<ServerHost>, ns.rec.net -> ns.<ServerHost>, and so on. The ns response
|
||||||
|
# then supplies every other service URL. Your DNS must resolve each subdomain
|
||||||
|
# (ns, api, auth, match, chat, cdn, config, ...) and the TLS cert must cover
|
||||||
|
# them. No default -- required.
|
||||||
|
#
|
||||||
|
# For a server on DorkNet's hyphen host style (Domain:HostStyle = hyphen, e.g.
|
||||||
|
# ns-dev.example.com), give a template instead: ServerHost = {service}-dev.example.com
|
||||||
|
ServerHost = example.com
|
||||||
|
|
||||||
|
# Android package name for the patched build, so it coexists with the store
|
||||||
|
# app and uses its own OBB path. Keep this stable: Meta ties platform tokens to
|
||||||
|
# package + signing cert, and the runtime-config path derives from it.
|
||||||
|
PackageName = net.dorknet.RecRoom
|
||||||
|
|
||||||
|
# Name shown in the headset library (Unknown Sources). Build-time only.
|
||||||
|
AppName = DorkNet
|
||||||
|
|
||||||
|
# ---- Meta (Oculus) platform ----
|
||||||
|
|
||||||
|
# Your registered Meta app id. Set it for the Meta build: real entitlement and
|
||||||
|
# Meta login run against an app your players own. Leave BLANK for the
|
||||||
|
# no-Meta-account build -- the platform entitlement/login path is steered to the
|
||||||
|
# client's own username/password screen. Blank sends platform id 1 from every
|
||||||
|
# headset; DorkNet's server treats that as a placeholder and keys accounts by
|
||||||
|
# device id (see PlayerService.IsPlaceholderPlatformId), so players are NOT
|
||||||
|
# merged. Never add a server rule that accepts platform id 1 as an identity.
|
||||||
|
OculusAppId =
|
||||||
|
|
||||||
|
# ---- Photon ----
|
||||||
|
|
||||||
|
# Your own Photon app ids (dashboard.photonengine.com). Rec Room's originals
|
||||||
|
# reject external clients. Leave an id blank to keep the client's own (dead)
|
||||||
|
# value -- multiplayer will not connect until these are set. No defaults.
|
||||||
|
PhotonAppId =
|
||||||
|
PhotonVoiceAppId =
|
||||||
|
PhotonChatAppId =
|
||||||
|
|
||||||
|
# Optional self-hosted Photon name server (blank / 0 = Photon Cloud).
|
||||||
|
PhotonNameServer =
|
||||||
|
PhotonNameServerPort = 0
|
||||||
|
|
||||||
|
# Photon Cloud region. Must match the PC clients' PhotonCloudRegion (ClientMod
|
||||||
|
# config), or Quest and PC players connect to different masters and never meet.
|
||||||
|
PhotonRegion = us
|
||||||
|
|
||||||
|
# ---- client behaviour ----
|
||||||
|
|
||||||
|
# Accept the self-hosted TLS chain and skip Rec Room's certificate pinning.
|
||||||
|
# Required for a self-hosted server; turn off only if you front it with a cert
|
||||||
|
# chain the client's bundled CA set trusts AND you reproduce Rec Room's pins.
|
||||||
|
EnableTlsTrustBypass = true
|
||||||
|
|
||||||
|
# Accept image Content-Signatures the server signs with its own key.
|
||||||
|
AcceptUnsignedImages = true
|
||||||
|
|
||||||
|
# A headset with no remembered account. false: open the sign-in page so the
|
||||||
|
# player logs into their existing DorkNet account. true: Rec Room's own
|
||||||
|
# behaviour, which creates a new account automatically -- only useful when the
|
||||||
|
# server allows sign-ups (otherwise the game keeps retrying and failing).
|
||||||
|
AutoCreateAccount = false
|
||||||
|
|
||||||
|
# ---- diagnostics (adb logcat -s DORKNET) ----
|
||||||
|
|
||||||
|
LogHttp = true
|
||||||
|
# WARNING: Debug logs request/response BODIES, including auth tokens and
|
||||||
|
# passwords. Leave off except when debugging, and don't share the logs.
|
||||||
|
Debug = false
|
||||||
|
LogHttpBodyMax = 10000
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build a DorkNet-patched Rec Room Quest APK (and optionally an .xapk release).
|
||||||
|
|
||||||
|
From a pristine store APK, end to end:
|
||||||
|
|
||||||
|
1. hook targets resolve them from an Il2CppDumper dump.cs
|
||||||
|
(resolve_targets.py), or use a committed table checked
|
||||||
|
against the APK's libil2cpp.so
|
||||||
|
2. headers targets_generated.h + config_generated.h
|
||||||
|
3. compile libdorknet.so (arm64) with the Android NDK
|
||||||
|
4. apktool decode
|
||||||
|
5. rename package rename, launcher category, app label
|
||||||
|
6. inject libdorknet.so in, DT_NEEDED into libmain.so (LIEF)
|
||||||
|
7. sign apktool build -> zipalign -> apksigner
|
||||||
|
8. package with --obb: .xapk + one-command installers (or run
|
||||||
|
`package` on an APK you already built)
|
||||||
|
|
||||||
|
Nothing game-derived is written into the repo. You supply the store APK and,
|
||||||
|
for a release package, the matching OBB. The output contains game binaries
|
||||||
|
from your own store copy: share it only with people who own the game.
|
||||||
|
|
||||||
|
Runs on Windows, Linux and macOS. Needs Python 3.9+ with LIEF, a JDK (java,
|
||||||
|
keytool), the Android NDK, Android build-tools and apktool.
|
||||||
|
|
||||||
|
py -3 build.py build --apk recroom.apk --obb main.2128210.com.AgainstGravity.RecRoom.obb \\
|
||||||
|
--targets-json ../targets/20230317-2128210.json --keystore dorknet.keystore
|
||||||
|
py -3 build.py package --apk dorknet-recroom-20230317.apk --obb main.2128210....obb
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NoReturn
|
||||||
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
|
|
||||||
|
SCRIPTS = Path(__file__).resolve().parent
|
||||||
|
REPO = SCRIPTS.parent # DorkNet.Quest/
|
||||||
|
OLD_PKG = "com.AgainstGravity.RecRoom"
|
||||||
|
SO_NAME = "libdorknet.so"
|
||||||
|
ON_WINDOWS = os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
|
def step(msg: str) -> None:
|
||||||
|
print(f"==> {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg: str) -> NoReturn:
|
||||||
|
raise SystemExit(f"error: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def need(path: Path | str, what: str) -> Path:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
fail(f"{what} not found: {p}")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def run(*cmd: object, cwd: Path | None = None) -> None:
|
||||||
|
args = [str(c) for c in cmd]
|
||||||
|
r = subprocess.run(args, cwd=cwd)
|
||||||
|
if r.returncode:
|
||||||
|
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
|
||||||
|
|
||||||
|
|
||||||
|
def capture(*cmd: object) -> str:
|
||||||
|
args = [str(c) for c in cmd]
|
||||||
|
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
if r.returncode:
|
||||||
|
sys.stderr.write(r.stderr)
|
||||||
|
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def run_py(script: str, *args: object) -> None:
|
||||||
|
"""Run a sibling helper script with this interpreter (isolated mode)."""
|
||||||
|
run(sys.executable, "-I", SCRIPTS / script, *args)
|
||||||
|
|
||||||
|
|
||||||
|
def exe(name: str) -> str:
|
||||||
|
return name + (".exe" if ON_WINDOWS else "")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- tool resolution ------------------------------------------------------
|
||||||
|
|
||||||
|
def sdk_root() -> Path | None:
|
||||||
|
v = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME")
|
||||||
|
return Path(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
def newest_subdir(parent: Path) -> Path | None:
|
||||||
|
if not parent.is_dir():
|
||||||
|
return None
|
||||||
|
dirs = sorted((d for d in parent.iterdir() if d.is_dir()), key=lambda d: d.name, reverse=True)
|
||||||
|
return dirs[0] if dirs else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_build_tools(arg: str | None) -> Path:
|
||||||
|
if arg:
|
||||||
|
return need(arg, "build-tools dir")
|
||||||
|
root = sdk_root()
|
||||||
|
found = newest_subdir(root / "build-tools") if root else None
|
||||||
|
if not found:
|
||||||
|
fail("Android build-tools not found. Pass --build-tools-dir or set ANDROID_SDK_ROOT.")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ndk(arg: str | None) -> Path:
|
||||||
|
if arg:
|
||||||
|
return need(arg, "NDK")
|
||||||
|
if os.environ.get("ANDROID_NDK"):
|
||||||
|
return need(os.environ["ANDROID_NDK"], "NDK (ANDROID_NDK)")
|
||||||
|
root = sdk_root()
|
||||||
|
found = newest_subdir(root / "ndk") if root else None
|
||||||
|
if not found:
|
||||||
|
fail("Android NDK not found. Pass --ndk-root or set ANDROID_NDK / ANDROID_SDK_ROOT.")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def ndk_bin(ndk: Path) -> Path:
|
||||||
|
if ON_WINDOWS:
|
||||||
|
tag = "windows-x86_64"
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
tag = "darwin-x86_64"
|
||||||
|
else:
|
||||||
|
tag = "linux-x86_64"
|
||||||
|
return ndk / "toolchains" / "llvm" / "prebuilt" / tag / "bin"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_apktool(arg: str | None) -> Path:
|
||||||
|
if arg:
|
||||||
|
return need(arg, "apktool.jar")
|
||||||
|
if os.environ.get("APKTOOL_JAR"):
|
||||||
|
return need(os.environ["APKTOOL_JAR"], "apktool.jar (APKTOOL_JAR)")
|
||||||
|
root = sdk_root()
|
||||||
|
if root and (root / "apktool.jar").exists():
|
||||||
|
return root / "apktool.jar"
|
||||||
|
raise SystemExit("error: apktool.jar not found. Pass --apktool-jar or set APKTOOL_JAR.")
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_value(config: Path, key: str) -> str | None:
|
||||||
|
for line in config.read_text(encoding="utf-8").splitlines():
|
||||||
|
m = re.match(rf"^\s*{re.escape(key)}\s*=(.*)$", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1).split("#", 1)[0].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- build ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_build(a: argparse.Namespace) -> None:
|
||||||
|
ndk = resolve_ndk(a.ndk_root)
|
||||||
|
bin_dir = ndk_bin(ndk)
|
||||||
|
cxx = need(bin_dir / (f"aarch64-linux-android{a.api_level}-clang++" + (".cmd" if ON_WINDOWS else "")),
|
||||||
|
"NDK aarch64 clang++")
|
||||||
|
objdump = bin_dir / exe("llvm-objdump")
|
||||||
|
build_tools = resolve_build_tools(a.build_tools_dir)
|
||||||
|
zipalign = need(build_tools / exe("zipalign"), "zipalign")
|
||||||
|
apksigner = need(build_tools / ("apksigner.bat" if ON_WINDOWS else "apksigner"), "apksigner")
|
||||||
|
apktool = resolve_apktool(a.apktool_jar)
|
||||||
|
apk = need(a.apk, "store APK").resolve()
|
||||||
|
|
||||||
|
config = Path(a.config) if a.config else (
|
||||||
|
REPO / "dorknet-quest.cfg" if (REPO / "dorknet-quest.cfg").exists() else REPO / "dorknet-quest.cfg.example")
|
||||||
|
need(config, "config")
|
||||||
|
print(f"Using config: {config}")
|
||||||
|
pkg_name = cfg_value(config, "PackageName") or "net.dorknet.RecRoom"
|
||||||
|
app_name = cfg_value(config, "AppName") or "DorkNet"
|
||||||
|
|
||||||
|
work = Path(a.work_dir)
|
||||||
|
gen, project, extract = work / "gen", work / "project", work / "extract"
|
||||||
|
for d in (work, gen, extract):
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. extract + targets
|
||||||
|
step("extracting libil2cpp.so + global-metadata.dat")
|
||||||
|
with zipfile.ZipFile(apk) as z:
|
||||||
|
for entry in ("lib/arm64-v8a/libil2cpp.so",
|
||||||
|
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
|
||||||
|
"lib/arm64-v8a/libmain.so"):
|
||||||
|
try:
|
||||||
|
data = z.read(entry)
|
||||||
|
except KeyError:
|
||||||
|
fail(f"APK missing {entry}")
|
||||||
|
(extract / Path(entry).name).write_bytes(data)
|
||||||
|
il2cpp = extract / "libil2cpp.so"
|
||||||
|
|
||||||
|
ver_name, ver_code = "20230317", "2128210"
|
||||||
|
if a.targets_json:
|
||||||
|
# A committed, already-resolved table. It is only valid for the exact
|
||||||
|
# libil2cpp.so it was resolved against, so check that first.
|
||||||
|
table = need(a.targets_json, "target table")
|
||||||
|
step(f"using target table {table}")
|
||||||
|
data = json.loads(table.read_text(encoding="utf-8"))
|
||||||
|
sha = hashlib.sha256(il2cpp.read_bytes()).hexdigest()
|
||||||
|
want = str(data.get("il2cppSha256") or "").lower()
|
||||||
|
if want and sha != want:
|
||||||
|
fail(f"libil2cpp.so in this APK ({sha}) is not the build the target table was resolved "
|
||||||
|
f"for ({want}). Resolve a table for it with --dump-cs.")
|
||||||
|
ver_name, ver_code = str(data["versionName"]), str(data["versionCode"])
|
||||||
|
else:
|
||||||
|
if not a.dump_cs:
|
||||||
|
# Dumping is left to Il2CppDumper (see README) to keep this
|
||||||
|
# script toolchain-light.
|
||||||
|
fail("Pass --dump-cs <dump.cs> from Il2CppDumper for this APK, or --targets-json with a "
|
||||||
|
"resolved table (see DorkNet.Quest/README.md).")
|
||||||
|
need(a.dump_cs, "dump.cs")
|
||||||
|
step("resolving hook targets")
|
||||||
|
# Written into the work dir (Controlled Folder Access can block writes
|
||||||
|
# under Documents). The repo keeps a committed copy under targets/.
|
||||||
|
table = work / f"{ver_name}-{ver_code}.json"
|
||||||
|
run_py("resolve_targets.py", "--dump", a.dump_cs, "--il2cpp", il2cpp,
|
||||||
|
"--version-name", ver_name, "--version-code", ver_code,
|
||||||
|
"--objdump", objdump, "--out", table)
|
||||||
|
|
||||||
|
# 2. headers
|
||||||
|
step("generating headers")
|
||||||
|
run_py("gen_targets_header.py", table, gen / "targets_generated.h")
|
||||||
|
run_py("gen_config_header.py", config, pkg_name, gen / "config_generated.h")
|
||||||
|
|
||||||
|
# 3. compile
|
||||||
|
step("compiling libdorknet.so (arm64)")
|
||||||
|
src = REPO / "src"
|
||||||
|
so = work / SO_NAME
|
||||||
|
run(cxx, "-std=c++17", "-fPIC", "-shared", "-O2", "-fvisibility=hidden", "-static-libstdc++",
|
||||||
|
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
|
||||||
|
f"-I{gen}", f"-I{src}",
|
||||||
|
src / "dorknet_quest.cpp", src / "config.cpp",
|
||||||
|
src / "third_party" / "And64InlineHook" / "And64InlineHook.cpp",
|
||||||
|
"-llog", "-o", so)
|
||||||
|
need(so, "libdorknet.so")
|
||||||
|
|
||||||
|
# 4. decode
|
||||||
|
step("apktool decode")
|
||||||
|
if project.exists():
|
||||||
|
shutil.rmtree(project)
|
||||||
|
run("java", "-jar", apktool, "decode", "-f", "-o", project, apk)
|
||||||
|
|
||||||
|
# 5. package rename + manifest + label
|
||||||
|
step(f"renaming package {OLD_PKG} -> {pkg_name}")
|
||||||
|
manifest = project / "AndroidManifest.xml"
|
||||||
|
m = manifest.read_text(encoding="utf-8").replace(OLD_PKG, pkg_name)
|
||||||
|
if "android.intent.category.LAUNCHER" not in m:
|
||||||
|
m = m.replace('<category android:name="android.intent.category.INFO"/>',
|
||||||
|
'<category android:name="android.intent.category.INFO"/>'
|
||||||
|
'<category android:name="android.intent.category.LAUNCHER"/>')
|
||||||
|
manifest.write_text(m, encoding="utf-8", newline="")
|
||||||
|
|
||||||
|
# The launcher label comes from @string/app_name; set it in every locale.
|
||||||
|
step(f"naming the app '{app_name}'")
|
||||||
|
# XML entities for & < > " ' (the PowerShell version used
|
||||||
|
# SecurityElement.Escape), so no raw quote reaches Android's string parser.
|
||||||
|
label = xml_escape(app_name, {'"': """, "'": "'"})
|
||||||
|
for sx in (project / "res").rglob("strings.xml"):
|
||||||
|
s = sx.read_text(encoding="utf-8")
|
||||||
|
n = re.sub(r'<string name="app_name">[^<]*</string>', lambda _: f'<string name="app_name">{label}</string>', s)
|
||||||
|
if n != s:
|
||||||
|
sx.write_text(n, encoding="utf-8", newline="")
|
||||||
|
|
||||||
|
# 6. inject
|
||||||
|
step(f"injecting {SO_NAME}")
|
||||||
|
shutil.copy2(so, project / "lib" / "arm64-v8a" / SO_NAME)
|
||||||
|
run_py("inject_needed.py", project / "lib" / "arm64-v8a" / "libmain.so", SO_NAME)
|
||||||
|
|
||||||
|
# 7. build + align + sign
|
||||||
|
step("apktool build")
|
||||||
|
unsigned = work / "unsigned.apk"
|
||||||
|
run("java", "-jar", apktool, "build", "-f", "-o", unsigned, project)
|
||||||
|
|
||||||
|
if a.keystore:
|
||||||
|
keystore = need(a.keystore, "keystore")
|
||||||
|
else:
|
||||||
|
keystore = work / "dorknet-debug.keystore"
|
||||||
|
if not keystore.exists():
|
||||||
|
step("generating a fresh debug keystore (kept in the work dir, never committed)")
|
||||||
|
run("keytool", "-genkeypair", "-v", "-keystore", keystore, "-alias", a.key_alias,
|
||||||
|
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
|
||||||
|
"-storepass", a.keystore_pass, "-keypass", a.keystore_pass,
|
||||||
|
"-dname", "CN=DorkNet Quest, O=DorkNet")
|
||||||
|
|
||||||
|
step("zipalign")
|
||||||
|
aligned = work / "aligned.apk"
|
||||||
|
run(zipalign, "-f", "-p", "4", unsigned, aligned)
|
||||||
|
|
||||||
|
out_apk = Path(a.out_apk) if a.out_apk else work / f"dorknet-recroom-{ver_name}.apk"
|
||||||
|
out_apk.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
step(f"apksigner -> {out_apk}")
|
||||||
|
run(apksigner, "sign", "--ks", keystore, "--ks-key-alias", a.key_alias,
|
||||||
|
"--ks-pass", f"pass:{a.keystore_pass}", "--key-pass", f"pass:{a.keystore_pass}",
|
||||||
|
"--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
|
||||||
|
"--out", out_apk, aligned)
|
||||||
|
capture(apksigner, "verify", "--print-certs", out_apk)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"DONE. Patched APK: {out_apk}")
|
||||||
|
print(f"Package: {pkg_name} versionCode: {ver_code}")
|
||||||
|
if a.obb:
|
||||||
|
print()
|
||||||
|
package(out_apk, Path(a.obb), Path(a.package_dir) if a.package_dir else None, build_tools)
|
||||||
|
else:
|
||||||
|
print("Install it, then push the matching OBB to:")
|
||||||
|
print(f" /sdcard/Android/obb/{pkg_name}/main.{ver_code}.{pkg_name}.obb")
|
||||||
|
print("(or pass --obb to bundle both into an installable .xapk)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- package --------------------------------------------------------------
|
||||||
|
|
||||||
|
README_TXT = """{name} {ver} - Rec Room for DorkNet (Meta Quest)
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
|
||||||
|
in over USB and accept "Allow USB debugging" inside the headset.
|
||||||
|
2. Install. Easiest: open the Quest page of your DorkNet server's website
|
||||||
|
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
|
||||||
|
steps. Or use the scripts here:
|
||||||
|
Windows: double-click install.cmd
|
||||||
|
macOS / Linux: ./install.sh
|
||||||
|
The installer needs adb (Android platform-tools) on PATH or in
|
||||||
|
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
|
||||||
|
offers to launch it.
|
||||||
|
3. In the headset: Library > Unknown Sources > {name}.
|
||||||
|
|
||||||
|
The app installs as {pkg} alongside any store copy of Rec Room.
|
||||||
|
SideQuest users can also drop the .xapk onto SideQuest instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def package(apk: Path, obb: Path, out_dir: Path | None, build_tools: Path) -> Path:
|
||||||
|
"""Bundle a patched APK and its OBB into an .xapk plus installers.
|
||||||
|
|
||||||
|
The XAPK is the usual APKPure layout (manifest.json + APK + OBB + icon), so
|
||||||
|
XAPK-aware installers (SideQuest's file drop, SAI, APKPure) can open it too.
|
||||||
|
The APK and OBB are stored uncompressed: they are already compressed, and
|
||||||
|
storing them keeps packaging and install fast."""
|
||||||
|
apk, obb = need(apk, "patched APK").resolve(), need(obb, "OBB").resolve()
|
||||||
|
aapt2 = need(build_tools / exe("aapt2"), "aapt2")
|
||||||
|
|
||||||
|
step("reading APK metadata")
|
||||||
|
badging = capture(aapt2, "dump", "badging", apk).splitlines()
|
||||||
|
|
||||||
|
def badge(pattern: str) -> str | None:
|
||||||
|
for line in badging:
|
||||||
|
mm = re.search(pattern, line)
|
||||||
|
if mm:
|
||||||
|
return mm.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
pkg = badge(r"^package: name='([^']+)'")
|
||||||
|
ver_code = badge(r"versionCode='([^']+)'")
|
||||||
|
ver_name = badge(r"versionName='([^']+)'")
|
||||||
|
min_sdk = badge(r"^(?:minSdkVersion|sdkVersion):'([^']+)'")
|
||||||
|
tgt_sdk = badge(r"^targetSdkVersion:'([^']+)'")
|
||||||
|
label = badge(r"^application-label:'([^']*)'")
|
||||||
|
if not pkg or not ver_code:
|
||||||
|
fail("could not read package name / versionCode from the APK")
|
||||||
|
perms = [mm.group(1) for line in badging if (mm := re.match(r"^uses-permission: name='([^']+)'", line))]
|
||||||
|
icons = sorted(((int(mm.group(1)), mm.group(2)) for line in badging
|
||||||
|
if (mm := re.match(r"^application-icon-(\d+):'([^']+)'", line))), reverse=True)
|
||||||
|
|
||||||
|
# The launcher icon may be an adaptive-icon XML; XAPK installers want a
|
||||||
|
# PNG, so fall back to the same-named PNG from the densest mipmap/drawable.
|
||||||
|
icon_path = None
|
||||||
|
with zipfile.ZipFile(apk) as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
if icons:
|
||||||
|
ref = icons[0][1]
|
||||||
|
if ref.endswith(".png") and ref in names:
|
||||||
|
icon_path = ref
|
||||||
|
else:
|
||||||
|
base = Path(ref).stem
|
||||||
|
for d in ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi", "ldpi"):
|
||||||
|
for kind in ("mipmap", "drawable"):
|
||||||
|
cand = f"res/{kind}-{d}/{base}.png"
|
||||||
|
if icon_path is None and cand in names:
|
||||||
|
icon_path = cand
|
||||||
|
icon_bytes = z.read(icon_path) if icon_path else None
|
||||||
|
print(f" {pkg} version {ver_name} ({ver_code})")
|
||||||
|
|
||||||
|
obb_entry = f"Android/obb/{pkg}/main.{ver_code}.{pkg}.obb"
|
||||||
|
out_dir = out_dir or apk.parent / f"DorkNet-Quest-{ver_name}"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
xapk = out_dir / f"DorkNet-{ver_name}.xapk"
|
||||||
|
xapk.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"xapk_version": 2,
|
||||||
|
"package_name": pkg,
|
||||||
|
"name": label or "DorkNet",
|
||||||
|
"version_code": ver_code,
|
||||||
|
"version_name": ver_name,
|
||||||
|
"min_sdk_version": min_sdk,
|
||||||
|
"target_sdk_version": tgt_sdk,
|
||||||
|
"permissions": perms,
|
||||||
|
"total_size": apk.stat().st_size + obb.stat().st_size,
|
||||||
|
"expansions": [{"file": obb_entry, "install_location": "EXTERNAL_STORAGE", "install_path": obb_entry}],
|
||||||
|
}
|
||||||
|
if icon_bytes:
|
||||||
|
manifest["icon"] = "icon.png"
|
||||||
|
|
||||||
|
step(f"writing {xapk} (the OBB is large; this takes a minute)")
|
||||||
|
with zipfile.ZipFile(xapk, "w", allowZip64=True) as z:
|
||||||
|
z.writestr(zipfile.ZipInfo("manifest.json"), json.dumps(manifest, indent=2), compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
if icon_bytes:
|
||||||
|
z.writestr("icon.png", icon_bytes, compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.write(apk, f"{pkg}.apk", compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.write(obb, obb_entry, compress_type=zipfile.ZIP_STORED)
|
||||||
|
|
||||||
|
step("adding installers")
|
||||||
|
for f in ("install.ps1", "install.cmd", "install.sh"):
|
||||||
|
shutil.copy2(SCRIPTS / "installer" / f, out_dir / f)
|
||||||
|
(out_dir / "install.sh").chmod(0o755)
|
||||||
|
(out_dir / "README.txt").write_text(
|
||||||
|
README_TXT.format(name=manifest["name"], ver=ver_name, pkg=pkg), encoding="utf-8")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"DONE. Package folder: {out_dir}")
|
||||||
|
print(f" {xapk.name} ({xapk.stat().st_size / 2**30:.2f} GB)")
|
||||||
|
print(f"Install with: {out_dir / 'install.cmd'}")
|
||||||
|
return out_dir
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_package(a: argparse.Namespace) -> None:
|
||||||
|
package(Path(a.apk), Path(a.obb), Path(a.out_dir) if a.out_dir else None,
|
||||||
|
resolve_build_tools(a.build_tools_dir))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- cli ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
b = sub.add_parser("build", help="store APK -> signed patched APK (+ .xapk with --obb)")
|
||||||
|
b.add_argument("--apk", required=True, help="pristine store APK (com.AgainstGravity.RecRoom)")
|
||||||
|
b.add_argument("--obb", help="matching store OBB; also writes the .xapk release package")
|
||||||
|
b.add_argument("--config", help="dorknet-quest.cfg (default: DorkNet.Quest/dorknet-quest.cfg, else the .example)")
|
||||||
|
b.add_argument("--dump-cs", help="Il2CppDumper dump.cs for this APK, to resolve the hook targets")
|
||||||
|
b.add_argument("--targets-json", help="a resolved target table to use instead of --dump-cs; must match the APK's libil2cpp.so")
|
||||||
|
b.add_argument("--keystore", help="signing keystore; reuse the same one for every build so updates install in place")
|
||||||
|
b.add_argument("--keystore-pass", default="dorknet", help="store and key password (default: dorknet)")
|
||||||
|
b.add_argument("--key-alias", default="dorknet", help="key alias (default: dorknet)")
|
||||||
|
b.add_argument("--work-dir", default=str(Path(tempfile.gettempdir()) / "dorknet-quest-build"),
|
||||||
|
help="scratch directory (on Windows keep it outside Controlled Folder Access, e.g. %%TEMP%%)")
|
||||||
|
b.add_argument("--out-apk", help="where to write the APK (default: <work-dir>/dorknet-recroom-<ver>.apk)")
|
||||||
|
b.add_argument("--package-dir", help="release folder for the .xapk (default: <apk folder>/DorkNet-Quest-<ver>)")
|
||||||
|
b.add_argument("--ndk-root", help="Android NDK root (default: ANDROID_NDK, else newest under ANDROID_SDK_ROOT/ndk)")
|
||||||
|
b.add_argument("--build-tools-dir", help="Android build-tools dir (default: newest under ANDROID_SDK_ROOT/build-tools)")
|
||||||
|
b.add_argument("--apktool-jar", help="apktool.jar (default: APKTOOL_JAR, else ANDROID_SDK_ROOT/apktool.jar)")
|
||||||
|
b.add_argument("--api-level", default="24", help="NDK API level (default: 24)")
|
||||||
|
b.set_defaults(func=cmd_build)
|
||||||
|
|
||||||
|
k = sub.add_parser("package", help="patched APK + OBB -> .xapk + installers")
|
||||||
|
k.add_argument("--apk", required=True)
|
||||||
|
k.add_argument("--obb", required=True)
|
||||||
|
k.add_argument("--out-dir", help="release folder (default: <apk folder>/DorkNet-Quest-<ver>)")
|
||||||
|
k.add_argument("--build-tools-dir")
|
||||||
|
k.set_defaults(func=cmd_package)
|
||||||
|
|
||||||
|
a = p.parse_args()
|
||||||
|
a.func(a)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate config_generated.h from dorknet-quest.cfg.
|
||||||
|
|
||||||
|
Bakes the operator's config into the library at compile time. Keys absent from
|
||||||
|
the cfg are simply not emitted, so the built-in defaults in config.h apply.
|
||||||
|
Secrets (Photon ids, Meta app id) live only in the git-ignored cfg, never in a
|
||||||
|
committed file.
|
||||||
|
|
||||||
|
Usage: gen_config_header.py <dorknet-quest.cfg> <package> <out/config_generated.h>
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# cfg key -> (macro, kind) where kind is "str", "bool" or "int".
|
||||||
|
KEYS = {
|
||||||
|
"ServerHost": ("DN_SERVER_HOST", "str"),
|
||||||
|
"OculusAppId": ("DN_OCULUS_APPID", "str"),
|
||||||
|
"PhotonAppId": ("DN_PHOTON_REALTIME", "str"),
|
||||||
|
"PhotonVoiceAppId": ("DN_PHOTON_VOICE", "str"),
|
||||||
|
"PhotonChatAppId": ("DN_PHOTON_CHAT", "str"),
|
||||||
|
"PhotonNameServer": ("DN_PHOTON_SERVER", "str"),
|
||||||
|
"PhotonNameServerPort": ("DN_PHOTON_PORT", "int"),
|
||||||
|
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
|
||||||
|
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
|
||||||
|
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
|
||||||
|
"AutoCreateAccount": ("DN_AUTO_CREATE_ACCOUNT", "bool"),
|
||||||
|
"LogHttp": ("DN_LOG_HTTP", "bool"),
|
||||||
|
"Debug": ("DN_DEBUG_HTTP", "bool"),
|
||||||
|
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
|
||||||
|
}
|
||||||
|
TRUE = {"true", "1", "yes", "on"}
|
||||||
|
FALSE = {"false", "0", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
def c_string(v):
|
||||||
|
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 4:
|
||||||
|
raise SystemExit("usage: gen_config_header.py <cfg> <package> <out.h>")
|
||||||
|
cfg, package, out = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])
|
||||||
|
lines = ["// Generated by scripts/gen_config_header.py -- do not edit, do not commit.",
|
||||||
|
"#ifndef DORKNET_QUEST_CONFIG_GENERATED_H",
|
||||||
|
"#define DORKNET_QUEST_CONFIG_GENERATED_H",
|
||||||
|
f"#define DN_PACKAGE {c_string(package)}", ""]
|
||||||
|
|
||||||
|
if cfg.exists():
|
||||||
|
for raw in cfg.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.split("#", 1)[0].strip()
|
||||||
|
if not line or "=" not in line or line.startswith("["):
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition("=")
|
||||||
|
key, val = key.strip(), val.strip()
|
||||||
|
if key not in KEYS or val == "":
|
||||||
|
continue
|
||||||
|
macro, kind = KEYS[key]
|
||||||
|
if kind == "str":
|
||||||
|
lines.append(f"#define {macro} {c_string(val)}")
|
||||||
|
elif kind == "int":
|
||||||
|
lines.append(f"#define {macro} {int(val)}")
|
||||||
|
elif kind == "bool":
|
||||||
|
low = val.lower()
|
||||||
|
if low in TRUE:
|
||||||
|
lines.append(f"#define {macro} true")
|
||||||
|
elif low in FALSE:
|
||||||
|
lines.append(f"#define {macro} false")
|
||||||
|
else:
|
||||||
|
raise SystemExit(f"{key}: not a boolean: {val}")
|
||||||
|
else:
|
||||||
|
print(f"note: {cfg} not found; baking package only, all values default", file=sys.stderr)
|
||||||
|
|
||||||
|
lines += ["", "#endif"]
|
||||||
|
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate targets_generated.h from a resolved target table.
|
||||||
|
|
||||||
|
The header holds each target's RVA, length and 20-byte FNV-1a fingerprint as a
|
||||||
|
named struct field, plus the build's version. The hook library checks the
|
||||||
|
fingerprint against the running libil2cpp.so before arming, so a table can only
|
||||||
|
ever be applied to the build it was resolved from.
|
||||||
|
|
||||||
|
Usage: gen_targets_header.py <targets/NAME-CODE.json> <out/targets_generated.h>
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
raise SystemExit("usage: gen_targets_header.py <table.json> <out.h>")
|
||||||
|
table = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
ids = list(table["targets"].keys())
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"// Generated by scripts/gen_targets_header.py -- do not edit.",
|
||||||
|
f"// Build {table['versionName']} / versionCode {table['versionCode']}",
|
||||||
|
f"// libil2cpp.so sha256 {table['il2cppSha256']}",
|
||||||
|
"#ifndef DORKNET_QUEST_TARGETS_GENERATED_H",
|
||||||
|
"#define DORKNET_QUEST_TARGETS_GENERATED_H",
|
||||||
|
"#include <stdint.h>",
|
||||||
|
"",
|
||||||
|
f'#define DN_TARGET_VERSION_NAME "{table["versionName"]}"',
|
||||||
|
f'#define DN_TARGET_VERSION_CODE "{table["versionCode"]}"',
|
||||||
|
f"#define DN_HOOK_BYTES {table['hookBytes']}",
|
||||||
|
"",
|
||||||
|
"typedef struct { const char *id; uint32_t rva; uint64_t fingerprint; uint32_t length; } DnTarget;",
|
||||||
|
"",
|
||||||
|
"typedef struct {",
|
||||||
|
]
|
||||||
|
for tid in ids:
|
||||||
|
lines.append(f" DnTarget {tid};")
|
||||||
|
lines.append("} DnTargets;")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("static const DnTargets dn_targets = {")
|
||||||
|
for tid in ids:
|
||||||
|
e = table["targets"][tid]
|
||||||
|
length = e["length"] if e["length"] is not None else 0
|
||||||
|
lines.append(
|
||||||
|
f' .{tid} = {{ "{tid}", {e["rva"]:#x}u, {e["fingerprint"]}ull, {length}u }},')
|
||||||
|
lines.append("};")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("#endif")
|
||||||
|
Path(sys.argv[2]).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {sys.argv[2]} ({len(ids)} targets)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Add a DT_NEEDED entry to libmain.so so the Android loader pulls in
|
||||||
|
libdorknet.so before Unity starts.
|
||||||
|
|
||||||
|
libmain.so is tiny (the Unity bootstrap shim) and lists libil2cpp etc. as
|
||||||
|
DT_NEEDED; adding one more makes the loader map our hook library first. This is
|
||||||
|
the same mechanism RecFlare uses on Quest. Idempotent: a second run is a no-op.
|
||||||
|
|
||||||
|
Usage: inject_needed.py <libmain.so> <needed-soname> (e.g. libdorknet.so)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import lief
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
raise SystemExit("usage: inject_needed.py <libmain.so> <soname>")
|
||||||
|
path, soname = sys.argv[1], sys.argv[2]
|
||||||
|
binary = lief.ELF.parse(path)
|
||||||
|
if binary is None:
|
||||||
|
raise SystemExit(f"could not parse {path}")
|
||||||
|
|
||||||
|
existing = [e.name for e in binary.dynamic_entries
|
||||||
|
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
|
||||||
|
if soname in existing:
|
||||||
|
print(f"{soname} already a DT_NEEDED of {path}; nothing to do")
|
||||||
|
return
|
||||||
|
|
||||||
|
binary.add_library(soname)
|
||||||
|
binary.write(path)
|
||||||
|
print(f"added DT_NEEDED {soname} to {path}")
|
||||||
|
|
||||||
|
check = lief.ELF.parse(path)
|
||||||
|
names = [e.name for e in check.dynamic_entries
|
||||||
|
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
|
||||||
|
if soname not in names:
|
||||||
|
raise SystemExit("injection did not stick")
|
||||||
|
print("verified:", ", ".join(names))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
rem One-click installer: runs install.ps1 next to this file.
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Install a DorkNet Rec Room .xapk onto a Meta Quest over adb.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Installs the APK, copies the OBB to the path the game expects, then launches
|
||||||
|
the app. Works in Windows PowerShell 5.1 and PowerShell 7. Run it with no
|
||||||
|
arguments from the package folder, or double-click install.cmd.
|
||||||
|
|
||||||
|
.PARAMETER Xapk
|
||||||
|
The package to install. Default: the only .xapk next to this script.
|
||||||
|
|
||||||
|
.PARAMETER Serial
|
||||||
|
adb serial of the headset when more than one device is connected.
|
||||||
|
|
||||||
|
.PARAMETER NoLaunch
|
||||||
|
Install only; don't start the app afterwards.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Xapk,
|
||||||
|
[string]$Serial,
|
||||||
|
[switch]$NoLaunch
|
||||||
|
)
|
||||||
|
|
||||||
|
# adb writes progress to stderr; under 'Stop' Windows PowerShell would treat
|
||||||
|
# that as a failure, so exit codes are checked by hand instead.
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
Set-StrictMode -Version 2
|
||||||
|
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
|
||||||
|
|
||||||
|
function Say($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||||
|
function Fail($m) { Write-Host "ERROR: $m" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
# ---- find the package ----------------------------------------------------
|
||||||
|
if (-not $Xapk) {
|
||||||
|
$found = @(Get-ChildItem -Path $PSScriptRoot -Filter *.xapk -File)
|
||||||
|
if ($found.Count -eq 0) { Fail "no .xapk found next to $PSCommandPath; pass -Xapk <file>." }
|
||||||
|
if ($found.Count -gt 1) { Fail "more than one .xapk here; pass -Xapk <file>." }
|
||||||
|
$Xapk = $found[0].FullName
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $Xapk)) { Fail "package not found: $Xapk" }
|
||||||
|
$Xapk = (Resolve-Path -LiteralPath $Xapk).Path
|
||||||
|
|
||||||
|
# ---- find adb -----------------------------------------------------------
|
||||||
|
$adb = $null
|
||||||
|
$cmd = Get-Command adb -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd) { $adb = $cmd.Source }
|
||||||
|
if (-not $adb) {
|
||||||
|
$roots = @($env:ANDROID_SDK_ROOT, $env:ANDROID_HOME, (Join-Path $env:LOCALAPPDATA 'Android\Sdk'), $PSScriptRoot) | Where-Object { $_ }
|
||||||
|
foreach ($r in $roots) {
|
||||||
|
foreach ($c in @((Join-Path $r 'platform-tools\adb.exe'), (Join-Path $r 'adb.exe'))) {
|
||||||
|
if (Test-Path $c) { $adb = $c; break }
|
||||||
|
}
|
||||||
|
if ($adb) { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $adb) {
|
||||||
|
Fail ("adb not found. Install Android platform-tools from " +
|
||||||
|
"https://developer.android.com/tools/releases/platform-tools and put adb on PATH " +
|
||||||
|
"(or unzip platform-tools next to this script).")
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- pick the headset ----------------------------------------------------
|
||||||
|
$lines = & $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match '\S' }
|
||||||
|
$ready = @($lines | Where-Object { $_ -match "^(\S+)\s+device$" } | ForEach-Object { ($_ -split '\s+')[0] })
|
||||||
|
$unauth = @($lines | Where-Object { $_ -match '\sunauthorized$' })
|
||||||
|
if ($Serial) {
|
||||||
|
if ($ready -notcontains $Serial) { Fail "device $Serial is not connected and authorised (adb devices)." }
|
||||||
|
} elseif ($ready.Count -eq 1) {
|
||||||
|
$Serial = $ready[0]
|
||||||
|
} elseif ($ready.Count -gt 1) {
|
||||||
|
Fail ("more than one device connected: " + ($ready -join ', ') + ". Pass -Serial <id>.")
|
||||||
|
} elseif ($unauth.Count -gt 0) {
|
||||||
|
Fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
|
||||||
|
} else {
|
||||||
|
Fail 'no headset found. Enable developer mode, connect it over USB, and accept "Allow USB debugging" in the headset.'
|
||||||
|
}
|
||||||
|
function Adb { & $adb -s $Serial @args }
|
||||||
|
$model = (Adb shell getprop ro.product.model | Out-String).Trim()
|
||||||
|
Say "headset: $model ($Serial)"
|
||||||
|
|
||||||
|
# ---- read the package ----------------------------------------------------
|
||||||
|
$zip = [System.IO.Compression.ZipFile]::OpenRead($Xapk)
|
||||||
|
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dorknet-install-" + [guid]::NewGuid().ToString('N'))
|
||||||
|
New-Item -ItemType Directory -Path $tmp | Out-Null
|
||||||
|
try {
|
||||||
|
$me = $zip.GetEntry('manifest.json')
|
||||||
|
if (-not $me) { Fail 'not an XAPK: manifest.json missing.' }
|
||||||
|
$sr = New-Object System.IO.StreamReader($me.Open())
|
||||||
|
$manifest = $sr.ReadToEnd() | ConvertFrom-Json
|
||||||
|
$sr.Dispose()
|
||||||
|
$pkg = $manifest.package_name
|
||||||
|
Say "package: $($manifest.name) $pkg $($manifest.version_name) ($($manifest.version_code))"
|
||||||
|
|
||||||
|
function Extract($entry) {
|
||||||
|
$out = Join-Path $tmp ([System.IO.Path]::GetFileName($entry.FullName))
|
||||||
|
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $out, $true)
|
||||||
|
return $out
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- APK -------------------------------------------------------------
|
||||||
|
$apkEntry = $zip.GetEntry("$pkg.apk")
|
||||||
|
if (-not $apkEntry) { $apkEntry = @($zip.Entries | Where-Object { $_.FullName -like '*.apk' })[0] }
|
||||||
|
if (-not $apkEntry) { Fail 'no APK inside the package.' }
|
||||||
|
Say 'installing the app'
|
||||||
|
$apkFile = Extract $apkEntry
|
||||||
|
$res = (Adb install -r $apkFile 2>&1 | Out-String)
|
||||||
|
if ($res -match 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match') {
|
||||||
|
Write-Host "An older copy of $pkg is installed with a different signing key." -ForegroundColor Yellow
|
||||||
|
Write-Host "It has to be removed first. Your account lives on the server, but local settings on the headset are lost." -ForegroundColor Yellow
|
||||||
|
$ans = Read-Host 'Uninstall it and continue? [y/N]'
|
||||||
|
if ($ans -notmatch '^(y|yes)$') { Fail 'cancelled.' }
|
||||||
|
Adb uninstall $pkg | Out-Null
|
||||||
|
$res = (Adb install -r $apkFile 2>&1 | Out-String)
|
||||||
|
}
|
||||||
|
if ($res -notmatch 'Success') { Fail ("app install failed:`n" + $res.Trim()) }
|
||||||
|
Remove-Item -Force $apkFile
|
||||||
|
|
||||||
|
# ---- OBB (expansion files) ------------------------------------------
|
||||||
|
foreach ($x in @($manifest.expansions)) {
|
||||||
|
if (-not $x) { continue }
|
||||||
|
$entry = $zip.GetEntry($x.file)
|
||||||
|
if (-not $entry) { Fail "package is missing $($x.file)." }
|
||||||
|
$dest = '/sdcard/' + $x.install_path
|
||||||
|
$have = (Adb shell "stat -c %s '$dest' 2>/dev/null" | Out-String).Trim()
|
||||||
|
if ($have -eq [string]$entry.Length) { Say "game data already on the headset, skipping ($([System.IO.Path]::GetFileName($dest)))"; continue }
|
||||||
|
Say ("copying game data ({0:N2} GB) - this takes a few minutes" -f ($entry.Length / 1GB))
|
||||||
|
$f = Extract $entry
|
||||||
|
Adb shell "mkdir -p '$([System.IO.Path]::GetDirectoryName($dest).Replace('\','/'))'" | Out-Null
|
||||||
|
Adb push $f $dest
|
||||||
|
if ($LASTEXITCODE) { Fail 'copying the game data failed (is there enough free space on the headset?).' }
|
||||||
|
Remove-Item -Force $f
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$zip.Dispose()
|
||||||
|
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Installed. In the headset: Library > Unknown Sources > $($manifest.name)." -ForegroundColor Green
|
||||||
|
if (-not $NoLaunch) {
|
||||||
|
Say 'launching (put the headset on)'
|
||||||
|
Adb shell monkey -p $pkg -c android.intent.category.LAUNCHER 1 2>&1 | Out-Null
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install a DorkNet Rec Room .xapk onto a Meta Quest over adb (macOS / Linux).
|
||||||
|
#
|
||||||
|
# ./install.sh [package.xapk] [--serial <id>] [--no-launch]
|
||||||
|
#
|
||||||
|
# Needs adb (Android platform-tools) and unzip.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
xapk="" serial="" launch=1
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--serial) serial="$2"; shift 2 ;;
|
||||||
|
--no-launch) launch=0; shift ;;
|
||||||
|
*) xapk="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
say() { printf '\033[36m==> %s\033[0m\n' "$*"; }
|
||||||
|
fail() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
if [ -z "$xapk" ]; then
|
||||||
|
set -- "$here"/*.xapk
|
||||||
|
[ -e "$1" ] || fail "no .xapk found next to this script; pass it as an argument."
|
||||||
|
[ $# -eq 1 ] || fail "more than one .xapk here; pass the one to install."
|
||||||
|
xapk="$1"
|
||||||
|
fi
|
||||||
|
[ -f "$xapk" ] || fail "package not found: $xapk"
|
||||||
|
command -v unzip >/dev/null || fail "unzip is required."
|
||||||
|
|
||||||
|
adb="$(command -v adb || true)"
|
||||||
|
for c in "${ANDROID_SDK_ROOT:-}/platform-tools/adb" "${ANDROID_HOME:-}/platform-tools/adb" \
|
||||||
|
"$HOME/Library/Android/sdk/platform-tools/adb" "$HOME/Android/Sdk/platform-tools/adb" \
|
||||||
|
"$here/platform-tools/adb"; do
|
||||||
|
[ -n "$adb" ] && break
|
||||||
|
[ -x "$c" ] && adb="$c"
|
||||||
|
done
|
||||||
|
[ -n "$adb" ] || fail "adb not found. Install Android platform-tools: https://developer.android.com/tools/releases/platform-tools"
|
||||||
|
|
||||||
|
if [ -z "$serial" ]; then
|
||||||
|
ready=$("$adb" devices | awk 'NR>1 && $2=="device"{print $1}')
|
||||||
|
count=$(printf '%s' "$ready" | grep -c . || true)
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
if "$adb" devices | grep -q unauthorized; then
|
||||||
|
fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
|
||||||
|
fi
|
||||||
|
fail 'no headset found. Enable developer mode, connect over USB, and accept "Allow USB debugging".'
|
||||||
|
fi
|
||||||
|
[ "$count" -eq 1 ] || fail "more than one device connected: $(echo $ready). Pass --serial <id>."
|
||||||
|
serial="$ready"
|
||||||
|
fi
|
||||||
|
a() { "$adb" -s "$serial" "$@"; }
|
||||||
|
say "headset: $(a shell getprop ro.product.model | tr -d '\r') ($serial)"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
manifest="$(unzip -p "$xapk" manifest.json)" || fail "not an XAPK: manifest.json missing."
|
||||||
|
field() { printf '%s' "$manifest" | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"\(.*\)"/\1/'; }
|
||||||
|
pkg="$(field package_name)"
|
||||||
|
[ -n "$pkg" ] || fail "manifest.json has no package_name."
|
||||||
|
say "package: $(field name) $pkg $(field version_name) ($(field version_code))"
|
||||||
|
|
||||||
|
say "installing the app"
|
||||||
|
unzip -q -o "$xapk" "$pkg.apk" -d "$tmp"
|
||||||
|
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
|
||||||
|
if printf '%s' "$out" | grep -qE 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match'; then
|
||||||
|
echo "An older copy of $pkg is installed with a different signing key; it has to be removed first."
|
||||||
|
echo "Your account lives on the server, but local settings on the headset are lost."
|
||||||
|
read -r -p "Uninstall it and continue? [y/N] " ans
|
||||||
|
case "$ans" in y|Y|yes) ;; *) fail "cancelled." ;; esac
|
||||||
|
a uninstall "$pkg" >/dev/null || true
|
||||||
|
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
|
||||||
|
fi
|
||||||
|
printf '%s' "$out" | grep -q Success || fail "app install failed: $out"
|
||||||
|
rm -f "$tmp/$pkg.apk"
|
||||||
|
|
||||||
|
# Expansion files: every "install_path" in the manifest.
|
||||||
|
printf '%s' "$manifest" | grep -o '"install_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"\(.*\)"/\1/' |
|
||||||
|
while read -r path; do
|
||||||
|
dest="/sdcard/$path"
|
||||||
|
size="$(unzip -l "$xapk" "$path" | awk 'NR==4{print $1}')"
|
||||||
|
have="$(a shell "stat -c %s '$dest' 2>/dev/null" | tr -d '\r' || true)"
|
||||||
|
if [ -n "$size" ] && [ "$have" = "$size" ]; then say "game data already on the headset, skipping"; continue; fi
|
||||||
|
say "copying game data - this takes a few minutes"
|
||||||
|
unzip -q -o "$xapk" "$path" -d "$tmp"
|
||||||
|
a shell "mkdir -p '$(dirname "$dest")'"
|
||||||
|
a push "$tmp/$path" "$dest" || fail "copying the game data failed (is there enough free space on the headset?)."
|
||||||
|
rm -f "$tmp/$path"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
printf '\033[32mInstalled. In the headset: Library > Unknown Sources > %s.\033[0m\n' "$(field name)"
|
||||||
|
if [ "$launch" -eq 1 ]; then
|
||||||
|
say "launching (put the headset on)"
|
||||||
|
a shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Resolve DorkNet.Quest hook targets for one Rec Room Quest build.
|
||||||
|
|
||||||
|
Reads targets/spec.json, finds every target in an Il2CppDumper dump.cs by its
|
||||||
|
class header and method signature, and verifies each against the build's
|
||||||
|
libil2cpp.so:
|
||||||
|
|
||||||
|
* exactly one method matches the signature,
|
||||||
|
* the method body is at least 20 bytes (the inline hook rewrites the first
|
||||||
|
five instructions),
|
||||||
|
* the first instructions are disassembled (llvm-objdump, when provided) so
|
||||||
|
the prologue can be eyeballed before a build.
|
||||||
|
|
||||||
|
Output is a per-build table (targets/<versionName>-<versionCode>.json) holding
|
||||||
|
each target's RVA, body length and an FNV-1a fingerprint of the first 20 code
|
||||||
|
bytes. The hook library refuses to arm a target whose fingerprint does not
|
||||||
|
match the running libil2cpp.so, so a table can never be applied to the wrong
|
||||||
|
build. Only RVAs, lengths and hashes are written -- no game code or metadata.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so \
|
||||||
|
--version-name 20230317 --version-code 2128210 \
|
||||||
|
[--objdump path/to/llvm-objdump] [--out targets/20230317-2128210.json]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HOOK_BYTES = 20
|
||||||
|
RVA_RE = re.compile(r"^\s*// RVA: 0x([0-9A-Fa-f]+) Offset: 0x([0-9A-Fa-f]+)")
|
||||||
|
TYPE_RE = re.compile(r"^(public|internal|private|protected)\b.*\b(class|struct|interface|enum)\b ")
|
||||||
|
|
||||||
|
|
||||||
|
def fnv1a64(data):
|
||||||
|
h = 0xCBF29CE484222325
|
||||||
|
for b in data:
|
||||||
|
h ^= b
|
||||||
|
h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path):
|
||||||
|
d = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||||
|
d.update(chunk)
|
||||||
|
return d.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def scan_dump(dump, spec):
|
||||||
|
"""One pass over dump.cs collecting (rva, offset) for each spec match and
|
||||||
|
for the NEXT method after it (to bound the body length)."""
|
||||||
|
wanted = {t["id"]: (t["class"], t["method"]) for t in spec["targets"]}
|
||||||
|
hits = {} # id -> {rva, offset, ...}
|
||||||
|
all_rvas = set() # every method RVA, for next-greater length bounding
|
||||||
|
rva = off = None
|
||||||
|
cur_class = None
|
||||||
|
lines = dump.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
for ln in lines:
|
||||||
|
m = RVA_RE.match(ln)
|
||||||
|
if m:
|
||||||
|
rva, off = int(m.group(1), 16), int(m.group(2), 16)
|
||||||
|
if rva:
|
||||||
|
all_rvas.add(rva)
|
||||||
|
continue
|
||||||
|
if TYPE_RE.match(ln):
|
||||||
|
# dump.cs class headers end with "// TypeDefIndex: N"; drop it.
|
||||||
|
cur_class = ln.split("// TypeDefIndex")[0].rstrip()
|
||||||
|
continue
|
||||||
|
# dump.cs method lines end with " { }" (or "; " for abstract); drop it.
|
||||||
|
body = ln.strip()
|
||||||
|
body = body.split("// RVA")[0].rstrip()
|
||||||
|
if body.endswith("{ }"):
|
||||||
|
body = body[:-3].rstrip()
|
||||||
|
elif body.endswith(";"):
|
||||||
|
body = body[:-1].rstrip()
|
||||||
|
for tid, (cls, meth) in wanted.items():
|
||||||
|
if tid in hits:
|
||||||
|
continue
|
||||||
|
# Class header may carry a base/interface list ("X : Base, IFoo").
|
||||||
|
# Match the exact class, or that class followed by " : <bases>",
|
||||||
|
# but never a nested "X.Y" (which continues with "." not " ").
|
||||||
|
spec_class = cls.rstrip()
|
||||||
|
class_ok = cur_class is not None and (
|
||||||
|
cur_class == spec_class or cur_class.startswith(spec_class + " "))
|
||||||
|
if class_ok and body == meth.rstrip():
|
||||||
|
if rva is None:
|
||||||
|
raise SystemExit(f"{tid}: matched signature with no preceding RVA line")
|
||||||
|
hits[tid] = {"rva": rva, "offset": off, "signature": meth,
|
||||||
|
"class": cls.rstrip()}
|
||||||
|
# Bound each body by the next-greater RVA anywhere in the dump: dump.cs
|
||||||
|
# lists methods in declaration order, so the following *line* is not the
|
||||||
|
# following *function* in memory.
|
||||||
|
ordered = sorted(all_rvas)
|
||||||
|
import bisect
|
||||||
|
for h in hits.values():
|
||||||
|
i = bisect.bisect_right(ordered, h["rva"])
|
||||||
|
h["end_rva"] = ordered[i] if i < len(ordered) else None
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def objdump_prologue(objdump, il2cpp, rva, count=6):
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
[objdump, "-d", f"--start-address={rva:#x}",
|
||||||
|
f"--stop-address={rva + count * 4:#x}", str(il2cpp)],
|
||||||
|
capture_output=True, text=True, check=True).stdout
|
||||||
|
except (OSError, subprocess.CalledProcessError) as e:
|
||||||
|
return f"(objdump failed: {e})"
|
||||||
|
return "\n".join(l for l in out.splitlines() if ":\t" in l)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dump", required=True, type=Path)
|
||||||
|
ap.add_argument("--il2cpp", required=True, type=Path)
|
||||||
|
ap.add_argument("--version-name", required=True)
|
||||||
|
ap.add_argument("--version-code", required=True)
|
||||||
|
ap.add_argument("--objdump")
|
||||||
|
ap.add_argument("--spec", type=Path,
|
||||||
|
default=Path(__file__).resolve().parent.parent / "targets" / "spec.json")
|
||||||
|
ap.add_argument("--out", type=Path)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
spec = json.loads(args.spec.read_text(encoding="utf-8"))
|
||||||
|
hits = scan_dump(args.dump, spec)
|
||||||
|
|
||||||
|
code = args.il2cpp.read_bytes()
|
||||||
|
missing = [t["id"] for t in spec["targets"] if t["id"] not in hits]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit("targets not found in dump: " + ", ".join(missing))
|
||||||
|
|
||||||
|
entries = {}
|
||||||
|
ok = True
|
||||||
|
for t in spec["targets"]:
|
||||||
|
tid = t["id"]
|
||||||
|
h = hits[tid]
|
||||||
|
rva = h["rva"]
|
||||||
|
# In this ELF Offset == VA == RVA, so the file offset is the RVA.
|
||||||
|
length = (h["end_rva"] - rva) if h["end_rva"] else None
|
||||||
|
fp = fnv1a64(code[rva:rva + HOOK_BYTES])
|
||||||
|
short = length is not None and length < HOOK_BYTES
|
||||||
|
if short:
|
||||||
|
ok = False
|
||||||
|
entries[tid] = {
|
||||||
|
"rva": rva,
|
||||||
|
"length": length,
|
||||||
|
"fingerprint": f"{fp:#018x}",
|
||||||
|
"why": t["why"],
|
||||||
|
}
|
||||||
|
flag = " TOO SHORT" if short else ""
|
||||||
|
print(f"{tid:28s} rva={rva:#010x} len={length}{flag}")
|
||||||
|
if args.objdump:
|
||||||
|
print(objdump_prologue(args.objdump, args.il2cpp, rva))
|
||||||
|
|
||||||
|
table = {
|
||||||
|
"versionName": args.version_name,
|
||||||
|
"versionCode": args.version_code,
|
||||||
|
"il2cppSha256": sha256(args.il2cpp),
|
||||||
|
"hookBytes": HOOK_BYTES,
|
||||||
|
"targets": entries,
|
||||||
|
}
|
||||||
|
out = args.out or (args.spec.parent / f"{args.version_name}-{args.version_code}.json")
|
||||||
|
out.write_text(json.dumps(table, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"\nwrote {out}")
|
||||||
|
if not ok:
|
||||||
|
raise SystemExit("one or more targets are shorter than the hook patch; refuse to build")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Runtime config loader: applies an optional on-device cfg over the baked-in
|
||||||
|
// defaults. The cfg format is "Key = Value", one per line, '#' comments and
|
||||||
|
// [section] headers ignored -- the same keys DorkNet.ClientMod uses on PC, so a
|
||||||
|
// PC config reads here too.
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
extern "C" int __android_log_print(int prio, const char *tag, const char *fmt, ...);
|
||||||
|
#define CLOGI(...) __android_log_print(4, "DORKNET", __VA_ARGS__)
|
||||||
|
|
||||||
|
static void trim(char *s) {
|
||||||
|
char *p = s;
|
||||||
|
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
|
||||||
|
if (p != s) memmove(s, p, strlen(p) + 1);
|
||||||
|
size_t n = strlen(s);
|
||||||
|
while (n && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r' || s[n - 1] == '\n'))
|
||||||
|
s[--n] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool as_bool(const char *v, bool dflt) {
|
||||||
|
if (!v || !*v) return dflt;
|
||||||
|
if (!strcasecmp(v, "true") || !strcasecmp(v, "1") || !strcasecmp(v, "yes") || !strcasecmp(v, "on"))
|
||||||
|
return true;
|
||||||
|
if (!strcasecmp(v, "false") || !strcasecmp(v, "0") || !strcasecmp(v, "no") || !strcasecmp(v, "off"))
|
||||||
|
return false;
|
||||||
|
return dflt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept a bare host or a full URL; store just the host.
|
||||||
|
static void set_host(char *out, size_t cap, const char *val) {
|
||||||
|
const char *scheme = strstr(val, "://");
|
||||||
|
const char *host = scheme ? scheme + 3 : val;
|
||||||
|
size_t i = 0;
|
||||||
|
for (; host[i] && host[i] != '/' && i + 1 < cap; i++) out[i] = host[i];
|
||||||
|
out[i] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void dn_load_runtime_config(char *server_host, size_t server_host_cap,
|
||||||
|
char *oculus_appid, size_t oculus_appid_cap,
|
||||||
|
char *photon_realtime, char *photon_voice, char *photon_chat,
|
||||||
|
char *photon_server, size_t photon_server_cap, int *photon_port,
|
||||||
|
char *photon_region, size_t photon_region_cap,
|
||||||
|
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
|
||||||
|
bool *log_http, bool *debug_http, int *body_max) {
|
||||||
|
char path[256];
|
||||||
|
snprintf(path, sizeof(path), "/sdcard/Android/data/%s/files/%s", DN_PACKAGE, DN_RUNTIME_CFG_NAME);
|
||||||
|
FILE *f = fopen(path, "r");
|
||||||
|
if (!f) { CLOGI("no runtime config at %s; using baked-in defaults", path); return; }
|
||||||
|
char line[256];
|
||||||
|
while (fgets(line, sizeof(line), f)) {
|
||||||
|
char *hash = strchr(line, '#');
|
||||||
|
if (hash) *hash = '\0';
|
||||||
|
char *eq = strchr(line, '=');
|
||||||
|
if (!eq) continue;
|
||||||
|
*eq = '\0';
|
||||||
|
char *key = line, *val = eq + 1;
|
||||||
|
trim(key);
|
||||||
|
trim(val);
|
||||||
|
if (!strcasecmp(key, "ServerHost") && *val) set_host(server_host, server_host_cap, val);
|
||||||
|
else if (!strcasecmp(key, "OculusAppId")) {
|
||||||
|
oculus_appid[0] = '\0';
|
||||||
|
strncpy(oculus_appid, val, oculus_appid_cap - 1);
|
||||||
|
}
|
||||||
|
else if (!strcasecmp(key, "PhotonAppId") && *val) strncpy(photon_realtime, val, 63);
|
||||||
|
else if (!strcasecmp(key, "PhotonVoiceAppId") && *val) strncpy(photon_voice, val, 63);
|
||||||
|
else if (!strcasecmp(key, "PhotonChatAppId") && *val) strncpy(photon_chat, val, 63);
|
||||||
|
else if (!strcasecmp(key, "PhotonNameServer")) set_host(photon_server, photon_server_cap, val);
|
||||||
|
else if (!strcasecmp(key, "PhotonNameServerPort")) *photon_port = atoi(val);
|
||||||
|
else if (!strcasecmp(key, "PhotonRegion") && *val) { photon_region[0] = '\0'; strncpy(photon_region, val, photon_region_cap - 1); }
|
||||||
|
else if (!strcasecmp(key, "EnableTlsTrustBypass")) *tls_bypass = as_bool(val, *tls_bypass);
|
||||||
|
else if (!strcasecmp(key, "AcceptUnsignedImages")) *accept_images = as_bool(val, *accept_images);
|
||||||
|
else if (!strcasecmp(key, "AutoCreateAccount")) *auto_create_account = as_bool(val, *auto_create_account);
|
||||||
|
else if (!strcasecmp(key, "LogHttp")) *log_http = as_bool(val, *log_http);
|
||||||
|
else if (!strcasecmp(key, "Debug")) *debug_http = as_bool(val, *debug_http);
|
||||||
|
else if (!strcasecmp(key, "LogHttpBodyMax") && *val) *body_max = atoi(val);
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
CLOGI("runtime config applied from %s", path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Compile-time configuration for libdorknet.so.
|
||||||
|
//
|
||||||
|
// scripts/gen_config_header.py turns dorknet-quest.cfg into config_generated.h
|
||||||
|
// (put on the include path by the build), baking the operator's values into the
|
||||||
|
// APK. Any key left out of the cfg falls back to the built-in default below.
|
||||||
|
// The same cfg can also be pushed to
|
||||||
|
// /sdcard/Android/data/<package>/files/dorknet-quest.cfg
|
||||||
|
// to override at runtime without a rebuild (dn_load_runtime_config).
|
||||||
|
#ifndef DORKNET_QUEST_CONFIG_H
|
||||||
|
#define DORKNET_QUEST_CONFIG_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#if defined(__has_include)
|
||||||
|
# if __has_include("config_generated.h")
|
||||||
|
# include "config_generated.h"
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// The apex the client's *.rec.net hosts are rewritten onto. "api.rec.net"
|
||||||
|
// becomes "api.<this>", "auth.rec.net" -> "auth.<this>", etc. No default: a
|
||||||
|
// build with no server configured would do nothing useful.
|
||||||
|
#ifndef DN_SERVER_HOST
|
||||||
|
# define DN_SERVER_HOST ""
|
||||||
|
#endif
|
||||||
|
// Empty = keep the client's own (Rec Room's) Meta app id, which selects the
|
||||||
|
// no-Meta-account build (platform entitlement/login are steered past).
|
||||||
|
#ifndef DN_OCULUS_APPID
|
||||||
|
# define DN_OCULUS_APPID ""
|
||||||
|
#endif
|
||||||
|
// Photon app ids: per-deployment, no default. Empty leaves the client's own
|
||||||
|
// (dead) value in place, so multiplayer will not connect until set.
|
||||||
|
#ifndef DN_PHOTON_REALTIME
|
||||||
|
# define DN_PHOTON_REALTIME ""
|
||||||
|
#endif
|
||||||
|
#ifndef DN_PHOTON_VOICE
|
||||||
|
# define DN_PHOTON_VOICE ""
|
||||||
|
#endif
|
||||||
|
#ifndef DN_PHOTON_CHAT
|
||||||
|
# define DN_PHOTON_CHAT ""
|
||||||
|
#endif
|
||||||
|
#ifndef DN_PHOTON_SERVER
|
||||||
|
# define DN_PHOTON_SERVER "" // "" = Photon Cloud
|
||||||
|
#endif
|
||||||
|
#ifndef DN_PHOTON_PORT
|
||||||
|
# define DN_PHOTON_PORT 0
|
||||||
|
#endif
|
||||||
|
// Photon Cloud fixed region. Must match the PC clients' region (ClientMod
|
||||||
|
// PhotonCloudRegion), or Quest and PC players land on different masters.
|
||||||
|
#ifndef DN_PHOTON_REGION
|
||||||
|
# define DN_PHOTON_REGION "us"
|
||||||
|
#endif
|
||||||
|
#ifndef DN_TLS_TRUST_BYPASS
|
||||||
|
# define DN_TLS_TRUST_BYPASS true // accept a self-hosted TLS chain + skip pinning
|
||||||
|
#endif
|
||||||
|
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
|
||||||
|
# define DN_ACCEPT_UNSIGNED_IMAGES true
|
||||||
|
#endif
|
||||||
|
// A headset with no remembered account: false opens the sign-in page, true
|
||||||
|
// keeps Rec Room's behaviour of creating a new account automatically.
|
||||||
|
#ifndef DN_AUTO_CREATE_ACCOUNT
|
||||||
|
# define DN_AUTO_CREATE_ACCOUNT false
|
||||||
|
#endif
|
||||||
|
#ifndef DN_LOG_HTTP
|
||||||
|
# define DN_LOG_HTTP true
|
||||||
|
#endif
|
||||||
|
#ifndef DN_DEBUG_HTTP
|
||||||
|
# define DN_DEBUG_HTTP false // also log request/response bodies (incl. tokens!)
|
||||||
|
#endif
|
||||||
|
#ifndef DN_LOG_BODY_MAX
|
||||||
|
# define DN_LOG_BODY_MAX 10000
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef DN_RUNTIME_CFG_NAME
|
||||||
|
# define DN_RUNTIME_CFG_NAME "dorknet-quest.cfg"
|
||||||
|
#endif
|
||||||
|
// Package the runtime cfg is read from; the build's gen step overrides this.
|
||||||
|
#ifndef DN_PACKAGE
|
||||||
|
# define DN_PACKAGE "net.dorknet.RecRoom"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Apply /sdcard/Android/data/<DN_PACKAGE>/files/dorknet-quest.cfg over the
|
||||||
|
// baked defaults. Each out pointer already holds the compiled-in value.
|
||||||
|
void dn_load_runtime_config(char *server_host, size_t server_host_cap,
|
||||||
|
char *oculus_appid, size_t oculus_appid_cap,
|
||||||
|
char *photon_realtime, char *photon_voice, char *photon_chat,
|
||||||
|
char *photon_server, size_t photon_server_cap, int *photon_port,
|
||||||
|
char *photon_region, size_t photon_region_cap,
|
||||||
|
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
|
||||||
|
bool *log_http, bool *debug_http, int *body_max);
|
||||||
|
|
||||||
|
#endif // DORKNET_QUEST_CONFIG_H
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
// DorkNet.Quest native hook library (libdorknet.so).
|
||||||
|
//
|
||||||
|
// Loaded via a DT_NEEDED entry added to libmain.so, so it runs before Unity.
|
||||||
|
// It waits for libil2cpp.so, then installs arm64 inline hooks (And64InlineHook)
|
||||||
|
// at RVAs resolved from the build's dump.cs by scripts/resolve_targets.py and
|
||||||
|
// baked into targets_generated.h at compile time. It is the Quest counterpart
|
||||||
|
// of DorkNet.ClientMod (the PC MelonLoader mod) and does the same jobs:
|
||||||
|
//
|
||||||
|
// * rewrite <service>.rec.net hosts to <service>.<ServerHost> (the ns
|
||||||
|
// response then supplies every other service URL) -- like ClientMod's
|
||||||
|
// UriPatches,
|
||||||
|
// * inject the operator's Photon app ids -- like the PC byte patch
|
||||||
|
// tools/patch-client.ps1,
|
||||||
|
// * optionally set the operator's Meta (Oculus) app id,
|
||||||
|
// * accept the self-hosted TLS chain and the image signature the operator's
|
||||||
|
// server produces -- like ClientMod's TlsPatches / the PC ImageSigning,
|
||||||
|
// * neutralise the integrity callbacks that would reject a re-signed,
|
||||||
|
// hook-loaded client -- like ClientMod's AntiTamper/ToxMod/FileHash patches,
|
||||||
|
// * when no Meta app id is set, steer the Oculus platform path past its
|
||||||
|
// entitlement/login checks to the client's own credential screen.
|
||||||
|
//
|
||||||
|
// Every RVA is checked against a 20-byte FNV-1a fingerprint from the target
|
||||||
|
// table before it is hooked, so the wrong build is refused rather than
|
||||||
|
// miswritten. Obfuscated Assembly-CSharp names are re-randomised per release
|
||||||
|
// but shared with the PC build of the same release; this build (20230317 /
|
||||||
|
// versionCode 2128210) shares its names with the PC 2023-03-21 decompile.
|
||||||
|
//
|
||||||
|
// No Rec Room code or assets are included. Third-party inline hooker in
|
||||||
|
// src/third_party/And64InlineHook (MIT, (c) Rprop).
|
||||||
|
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <strings.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include "third_party/And64InlineHook/And64InlineHook.hpp"
|
||||||
|
#include "config.h"
|
||||||
|
#include "targets_generated.h"
|
||||||
|
|
||||||
|
extern "C" int __android_log_print(int prio, const char *tag, const char *fmt, ...);
|
||||||
|
#define LOGI(...) __android_log_print(4, "DORKNET", __VA_ARGS__)
|
||||||
|
#define LOGE(...) __android_log_print(6, "DORKNET", __VA_ARGS__)
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- config -----
|
||||||
|
|
||||||
|
static const char *kOfficialSuffix = ".rec.net";
|
||||||
|
|
||||||
|
static char g_server_host[160] = DN_SERVER_HOST; // <service>.rec.net -> <service>.<this>
|
||||||
|
static char g_oculus_appid[64] = DN_OCULUS_APPID; // "" = keep the client's own
|
||||||
|
static char g_photon_realtime[64] = DN_PHOTON_REALTIME;
|
||||||
|
static char g_photon_voice[64] = DN_PHOTON_VOICE;
|
||||||
|
static char g_photon_chat[64] = DN_PHOTON_CHAT;
|
||||||
|
static char g_photon_server[128] = DN_PHOTON_SERVER; // "" = Photon Cloud
|
||||||
|
static int g_photon_port = DN_PHOTON_PORT;
|
||||||
|
static char g_photon_region[16] = DN_PHOTON_REGION;
|
||||||
|
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
|
||||||
|
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
|
||||||
|
static bool g_auto_create_account = DN_AUTO_CREATE_ACCOUNT;
|
||||||
|
static bool g_log_http = DN_LOG_HTTP;
|
||||||
|
static bool g_debug_http = DN_DEBUG_HTTP;
|
||||||
|
static int g_body_max = DN_LOG_BODY_MAX;
|
||||||
|
|
||||||
|
// AppSettings field offsets (dump.cs; unchanged across the 2023 builds).
|
||||||
|
static const size_t kAppIdRealtimeOff = 0x10;
|
||||||
|
static const size_t kAppIdChatOff = 0x18;
|
||||||
|
static const size_t kAppIdVoiceOff = 0x20;
|
||||||
|
static const size_t kUseNameServerOff = 0x30; // bool
|
||||||
|
static const size_t kFixedRegionOff = 0x38;
|
||||||
|
static const size_t kServerOff = 0x48;
|
||||||
|
static const size_t kPortOff = 0x50; // int
|
||||||
|
static const size_t kProtocolOff = 0x60; // int (Udp = 0)
|
||||||
|
|
||||||
|
// HTTPRequest / HTTPResponse field offsets (dump.cs).
|
||||||
|
static const size_t kUriFieldOffset = 0x10; // HTTPRequest.<Uri>
|
||||||
|
static const size_t kReqMethodOffset = 0x18; // HTTPRequest.<MethodType> (byte)
|
||||||
|
static const size_t kReqResponseOffset = 0x70; // HTTPRequest.<Response>
|
||||||
|
static const size_t kReqExceptionOffset = 0x80; // HTTPRequest.<Exception>
|
||||||
|
static const size_t kReqStateOffset = 0xB4; // HTTPRequest.<State> (int)
|
||||||
|
static const size_t kRespStatusOffset = 0x18; // HTTPResponse.<StatusCode> (int)
|
||||||
|
static const size_t kRespDataOffset = 0x48; // HTTPResponse.<Data> (byte[])
|
||||||
|
|
||||||
|
// Il2CppArray data layout on 64-bit.
|
||||||
|
static const size_t kArrayLengthOff = 0x18;
|
||||||
|
static const size_t kArrayDataOff = 0x20;
|
||||||
|
|
||||||
|
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
|
||||||
|
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
|
||||||
|
static const unsigned kMsgUserGetUserProof = 578880643u;
|
||||||
|
static const unsigned kMsgUserGetLoggedInUser = 1131361373u;
|
||||||
|
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
|
||||||
|
static const unsigned long long kNoPlatformUserId = 1ULL;
|
||||||
|
// Stand-in ovrUserHandle for a GetLoggedInUser that Meta refused. Only our
|
||||||
|
// hooked ovr_User_* accessors ever see it; they never pass it to the SDK.
|
||||||
|
static void *const kPlaceholderUser = (void *)0x2;
|
||||||
|
|
||||||
|
static const size_t kLogChunk = 1500;
|
||||||
|
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
|
||||||
|
static const size_t kErrorStateCodeOff = 0x4;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- il2cpp ------
|
||||||
|
|
||||||
|
typedef void *(*il2cpp_object_get_class_t)(void *obj);
|
||||||
|
typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *name, int argc);
|
||||||
|
typedef void *(*il2cpp_object_new_t)(void *klass);
|
||||||
|
typedef void *(*il2cpp_string_new_t)(const char *str);
|
||||||
|
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
|
||||||
|
typedef void *(*il2cpp_domain_get_t)();
|
||||||
|
typedef void **(*il2cpp_domain_get_assemblies_t)(void *domain, size_t *size);
|
||||||
|
typedef void *(*il2cpp_assembly_get_image_t)(void *assembly);
|
||||||
|
typedef const char *(*il2cpp_image_get_name_t)(void *image);
|
||||||
|
typedef void *(*il2cpp_class_from_name_t)(void *image, const char *ns, const char *name);
|
||||||
|
|
||||||
|
static il2cpp_object_get_class_t il2cpp_object_get_class;
|
||||||
|
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
|
||||||
|
static il2cpp_object_new_t il2cpp_object_new;
|
||||||
|
static il2cpp_string_new_t il2cpp_string_new;
|
||||||
|
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
|
||||||
|
static il2cpp_domain_get_t il2cpp_domain_get;
|
||||||
|
static il2cpp_domain_get_assemblies_t il2cpp_domain_get_assemblies;
|
||||||
|
static il2cpp_assembly_get_image_t il2cpp_assembly_get_image;
|
||||||
|
static il2cpp_image_get_name_t il2cpp_image_get_name;
|
||||||
|
static il2cpp_class_from_name_t il2cpp_class_from_name;
|
||||||
|
|
||||||
|
static uintptr_t g_il2cpp_base;
|
||||||
|
|
||||||
|
// Il2CppString: [klass][monitor][int32 length][char16 chars...]
|
||||||
|
static void str_to_utf8(void *s, char *out, size_t cap) {
|
||||||
|
out[0] = '\0';
|
||||||
|
if (!s || cap == 0) return;
|
||||||
|
int32_t len = *(int32_t *)((char *)s + 0x10);
|
||||||
|
if (len < 0) return;
|
||||||
|
const uint16_t *chars = (const uint16_t *)((char *)s + 0x14);
|
||||||
|
size_t i = 0;
|
||||||
|
for (; i < (size_t)len && i + 1 < cap; i++)
|
||||||
|
out[i] = chars[i] < 0x80 ? (char)chars[i] : '?';
|
||||||
|
out[i] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *len) {
|
||||||
|
*len = 0;
|
||||||
|
if (!obj) return nullptr;
|
||||||
|
void *cls = il2cpp_object_get_class(obj);
|
||||||
|
if (!cls) return nullptr;
|
||||||
|
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||||
|
if (!m) return nullptr;
|
||||||
|
void *exc = nullptr;
|
||||||
|
void *arr = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||||
|
if (!arr || exc) return nullptr;
|
||||||
|
*len = (size_t) * (uint64_t *)((char *)arr + kArrayLengthOff);
|
||||||
|
return (const unsigned char *)((char *)arr + kArrayDataOff);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool invoke_void(void *obj, const char *method) {
|
||||||
|
if (!obj) return false;
|
||||||
|
void *cls = il2cpp_object_get_class(obj);
|
||||||
|
if (!cls) return false;
|
||||||
|
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||||
|
if (!m) return false;
|
||||||
|
void *exc = nullptr;
|
||||||
|
il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||||
|
return !exc;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void *find_class(const char *image_name, const char *ns, const char *name) {
|
||||||
|
if (!il2cpp_domain_get || !il2cpp_domain_get_assemblies || !il2cpp_assembly_get_image ||
|
||||||
|
!il2cpp_image_get_name || !il2cpp_class_from_name) return nullptr;
|
||||||
|
size_t n = 0;
|
||||||
|
void **assemblies = il2cpp_domain_get_assemblies(il2cpp_domain_get(), &n);
|
||||||
|
for (size_t i = 0; assemblies && i < n; i++) {
|
||||||
|
void *image = il2cpp_assembly_get_image(assemblies[i]);
|
||||||
|
const char *iname = image ? il2cpp_image_get_name(image) : nullptr;
|
||||||
|
if (iname && !strcmp(iname, image_name)) return il2cpp_class_from_name(image, ns, name);
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
|
||||||
|
out[0] = '\0';
|
||||||
|
if (!obj) return;
|
||||||
|
void *cls = il2cpp_object_get_class(obj);
|
||||||
|
if (!cls) return;
|
||||||
|
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||||
|
if (!m) return;
|
||||||
|
void *exc = nullptr;
|
||||||
|
void *s = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||||
|
if (s && !exc) str_to_utf8(s, out, cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- logging ----
|
||||||
|
|
||||||
|
static void log_chunked(const char *prefix, const char *text) {
|
||||||
|
size_t n = strlen(text);
|
||||||
|
if (n <= kLogChunk) { LOGI("%s%s", prefix, text); return; }
|
||||||
|
char buf[kLogChunk + 1];
|
||||||
|
for (size_t off = 0, part = 1; off < n; off += kLogChunk, part++) {
|
||||||
|
size_t take = n - off < kLogChunk ? n - off : kLogChunk;
|
||||||
|
memcpy(buf, text + off, take);
|
||||||
|
buf[take] = '\0';
|
||||||
|
LOGI("%s[%zu] %s", prefix, part, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool looks_binary(const unsigned char *d, size_t n) {
|
||||||
|
if (n == 0) return false;
|
||||||
|
size_t sample = n < 4096 ? n : 4096, nontext = 0;
|
||||||
|
for (size_t i = 0; i < sample; i++) {
|
||||||
|
unsigned char b = d[i];
|
||||||
|
if (b == 0) return true;
|
||||||
|
if (b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) nontext++;
|
||||||
|
}
|
||||||
|
return nontext * 100 / sample > 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void format_body(const unsigned char *d, size_t n, char *out, size_t cap) {
|
||||||
|
if (!d || n == 0) { snprintf(out, cap, "<empty>"); return; }
|
||||||
|
size_t limit = (size_t)g_body_max;
|
||||||
|
if (limit > cap - 64) limit = cap - 64;
|
||||||
|
if (!looks_binary(d, n)) {
|
||||||
|
size_t take = n < limit ? n : limit;
|
||||||
|
memcpy(out, d, take);
|
||||||
|
out[take] = '\0';
|
||||||
|
if (take < n) snprintf(out + take, cap - take, "... <truncated %zu bytes>", n - take);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int w = snprintf(out, cap, "<binary %zu bytes>", n);
|
||||||
|
out[w < 0 ? 0 : (size_t)w] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- hooks -------
|
||||||
|
|
||||||
|
static void *(*orig_SendRequest)(void *request);
|
||||||
|
|
||||||
|
static void *hk_SendRequest(void *request) {
|
||||||
|
if (!request) return orig_SendRequest(request);
|
||||||
|
void *uri = *(void **)((char *)request + kUriFieldOffset);
|
||||||
|
if (!uri) return orig_SendRequest(request);
|
||||||
|
void *uri_class = il2cpp_object_get_class(uri);
|
||||||
|
if (!uri_class) return orig_SendRequest(request);
|
||||||
|
|
||||||
|
void *m_abs = il2cpp_class_get_method_from_name(uri_class, "get_AbsoluteUri", 0);
|
||||||
|
if (!m_abs) return orig_SendRequest(request);
|
||||||
|
void *exc = nullptr;
|
||||||
|
void *s = il2cpp_runtime_invoke(m_abs, uri, nullptr, &exc);
|
||||||
|
if (!s || exc) return orig_SendRequest(request);
|
||||||
|
|
||||||
|
char url[2048];
|
||||||
|
str_to_utf8(s, url, sizeof(url));
|
||||||
|
|
||||||
|
if (g_log_http) {
|
||||||
|
int method = *(unsigned char *)((char *)request + kReqMethodOffset);
|
||||||
|
static const char *methods[] = {"GET", "HEAD", "POST", "PUT", "DELETE",
|
||||||
|
"PATCH", "MERGE", "OPTIONS", "CONNECT"};
|
||||||
|
const char *mname = (method >= 0 && method < 9) ? methods[method] : "?";
|
||||||
|
LOGI("[http] %s %s", mname, url);
|
||||||
|
if (g_debug_http) {
|
||||||
|
size_t blen = 0;
|
||||||
|
const unsigned char *body = invoke_bytes(request, "GetEntityBody", &blen);
|
||||||
|
if (body && blen) {
|
||||||
|
size_t cap = (size_t)g_body_max + 128;
|
||||||
|
char *text = (char *)malloc(cap);
|
||||||
|
if (text) { format_body(body, blen, text, cap); log_chunked("[http] body=", text); free(text); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite any "<service>.rec.net" host to "<service>.<ServerHost>".
|
||||||
|
const char *scheme_end = strstr(url, "://");
|
||||||
|
if (!scheme_end) return orig_SendRequest(request);
|
||||||
|
const char *host = scheme_end + 3;
|
||||||
|
const char *host_end = host;
|
||||||
|
while (*host_end && *host_end != '/' && *host_end != ':' && *host_end != '?') host_end++;
|
||||||
|
size_t host_len = (size_t)(host_end - host);
|
||||||
|
size_t suf = strlen(kOfficialSuffix);
|
||||||
|
if (host_len <= suf || strncasecmp(host_end - suf, kOfficialSuffix, suf) != 0)
|
||||||
|
return orig_SendRequest(request);
|
||||||
|
|
||||||
|
char newurl[1024];
|
||||||
|
size_t head = (size_t)(host - url); // "https://"
|
||||||
|
size_t service_len = host_len - suf; // "api", "auth", ...
|
||||||
|
// ServerHost is either a plain apex ("example.com" -> "api.example.com") or a
|
||||||
|
// template with "{service}" for servers on DorkNet's hyphen host style
|
||||||
|
// ("{service}-dev.example.com" -> "ns-dev.example.com").
|
||||||
|
static const char kServiceToken[] = "{service}";
|
||||||
|
const char *token = strstr(g_server_host, kServiceToken);
|
||||||
|
size_t new_host_len = token ? strlen(g_server_host) - (sizeof(kServiceToken) - 1) + service_len
|
||||||
|
: service_len + 1 + strlen(g_server_host);
|
||||||
|
if (head + new_host_len + strlen(host_end) + 1 >= sizeof(newurl))
|
||||||
|
return orig_SendRequest(request);
|
||||||
|
memcpy(newurl, url, head);
|
||||||
|
size_t p = head;
|
||||||
|
if (token) {
|
||||||
|
size_t before = (size_t)(token - g_server_host);
|
||||||
|
memcpy(newurl + p, g_server_host, before);
|
||||||
|
p += before;
|
||||||
|
memcpy(newurl + p, host, service_len);
|
||||||
|
p += service_len;
|
||||||
|
strcpy(newurl + p, token + sizeof(kServiceToken) - 1);
|
||||||
|
} else {
|
||||||
|
memcpy(newurl + p, host, service_len);
|
||||||
|
p += service_len;
|
||||||
|
newurl[p++] = '.';
|
||||||
|
strcpy(newurl + p, g_server_host);
|
||||||
|
}
|
||||||
|
strcat(newurl + head, host_end);
|
||||||
|
|
||||||
|
void *new_str = il2cpp_string_new(newurl);
|
||||||
|
void *new_uri = il2cpp_object_new(uri_class);
|
||||||
|
void *ctor = il2cpp_class_get_method_from_name(uri_class, ".ctor", 1);
|
||||||
|
if (!new_str || !new_uri || !ctor) return orig_SendRequest(request);
|
||||||
|
void *args[1] = {new_str};
|
||||||
|
exc = nullptr;
|
||||||
|
il2cpp_runtime_invoke(ctor, new_uri, args, &exc);
|
||||||
|
if (exc) return orig_SendRequest(request);
|
||||||
|
*(void **)((char *)request + kUriFieldOffset) = new_uri;
|
||||||
|
LOGI("intercepted %s -> %s", url, newurl);
|
||||||
|
return orig_SendRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void (*orig_CallCallback)(void *thiz, void *method);
|
||||||
|
static void hk_CallCallback(void *thiz, void *method) {
|
||||||
|
if (!thiz || !g_log_http) { orig_CallCallback(thiz, method); return; }
|
||||||
|
char url[2048];
|
||||||
|
invoke_str(*(void **)((char *)thiz + kUriFieldOffset), "get_AbsoluteUri", url, sizeof(url));
|
||||||
|
int state = *(int *)((char *)thiz + kReqStateOffset);
|
||||||
|
void *resp = *(void **)((char *)thiz + kReqResponseOffset);
|
||||||
|
if (!resp) {
|
||||||
|
void *ex = *(void **)((char *)thiz + kReqExceptionOffset);
|
||||||
|
if (ex) {
|
||||||
|
char msg[900];
|
||||||
|
invoke_str(ex, "ToString", msg, sizeof(msg));
|
||||||
|
LOGI("[http<-] NO-RESPONSE state=%d %s | %s", state, url, msg);
|
||||||
|
} else {
|
||||||
|
LOGI("[http<-] NO-RESPONSE state=%d %s | (no exception)", state, url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
int status = *(int *)((char *)resp + kRespStatusOffset);
|
||||||
|
LOGI("[http<-] %d %s", status, url);
|
||||||
|
if (g_debug_http || status < 200 || status >= 300) {
|
||||||
|
size_t blen = 0;
|
||||||
|
const unsigned char *data = invoke_bytes(resp, "get_Data", &blen);
|
||||||
|
size_t cap = (size_t)g_body_max + 128;
|
||||||
|
char *text = (char *)malloc(cap);
|
||||||
|
if (text) { format_body(data, blen, text, cap); log_chunked("[http<-] body=", text); free(text); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orig_CallCallback(thiz, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void *hk_get_AppID(void *method) {
|
||||||
|
(void)method;
|
||||||
|
return il2cpp_string_new(g_oculus_appid);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void *(*orig_Photon)(void *thiz, void *method);
|
||||||
|
static void *hk_Photon(void *thiz, void *method) {
|
||||||
|
void *s = orig_Photon(thiz, method);
|
||||||
|
if (s) {
|
||||||
|
if (g_photon_realtime[0]) *(void **)((char *)s + kAppIdRealtimeOff) = il2cpp_string_new(g_photon_realtime);
|
||||||
|
if (g_photon_chat[0]) *(void **)((char *)s + kAppIdChatOff) = il2cpp_string_new(g_photon_chat);
|
||||||
|
if (g_photon_voice[0]) *(void **)((char *)s + kAppIdVoiceOff) = il2cpp_string_new(g_photon_voice);
|
||||||
|
*(unsigned char *)((char *)s + kUseNameServerOff) = 1;
|
||||||
|
*(void **)((char *)s + kFixedRegionOff) = il2cpp_string_new(g_photon_region);
|
||||||
|
*(int *)((char *)s + kProtocolOff) = 0;
|
||||||
|
if (g_photon_server[0]) {
|
||||||
|
*(void **)((char *)s + kServerOff) = il2cpp_string_new(g_photon_server);
|
||||||
|
*(int *)((char *)s + kPortOff) = g_photon_port ? g_photon_port : 4533;
|
||||||
|
}
|
||||||
|
static int logged = 0;
|
||||||
|
if (logged < 2) {
|
||||||
|
logged++;
|
||||||
|
LOGI("photon appsettings: RT=%s Voice=%s Chat=%s region=%s server=%s port=%d",
|
||||||
|
g_photon_realtime[0] ? g_photon_realtime : "(unset)",
|
||||||
|
g_photon_voice[0] ? g_photon_voice : "(unset)",
|
||||||
|
g_photon_chat[0] ? g_photon_chat : "(unset)", g_photon_region,
|
||||||
|
g_photon_server[0] ? g_photon_server : "(cloud)", g_photon_port);
|
||||||
|
if (!g_photon_realtime[0])
|
||||||
|
LOGE("photon app ids are not configured; multiplayer will try Rec Room's dead apps");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t (*orig_UnityTls)(void *thiz, uint64_t chain, void *es, void *method);
|
||||||
|
static uint32_t hk_UnityTls(void *thiz, uint64_t chain, void *es, void *method) {
|
||||||
|
uint32_t r = orig_UnityTls(thiz, chain, es, method);
|
||||||
|
if (r != kUnityTlsSuccess) {
|
||||||
|
static int logged = 0;
|
||||||
|
if (logged < 3) { logged++; LOGI("unitytls verify: forcing 0x%x -> SUCCESS", r); }
|
||||||
|
if (es) *(uint32_t *)((char *)es + kErrorStateCodeOff) = kUnityTlsSuccess;
|
||||||
|
}
|
||||||
|
return kUnityTlsSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
// void NotifyServerCertificate(Certificate): a no-op accepts the chain.
|
||||||
|
static void hk_NotifyServerCertificate(void *thiz, void *cert, void *method) {
|
||||||
|
(void)thiz; (void)cert; (void)method;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bool pin verifiers: accept unconditionally (replace-only, so no mismatch
|
||||||
|
// report is ever built and the client never quits).
|
||||||
|
static bool hk_pin_true4(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
|
||||||
|
static bool hk_pin_true4b(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
|
||||||
|
|
||||||
|
static bool hk_image_true(void *data, void *sig, void *method) {
|
||||||
|
(void)data; (void)sig; (void)method;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// void callbacks the client runs on an integrity rejection -- no-op them so a
|
||||||
|
// re-signed, hook-loaded sideload is not warned/quit (as DorkNet.ClientMod does
|
||||||
|
// on PC). The quit funnels and the report are left to run but are logged.
|
||||||
|
static void hk_void0(void *thiz, void *method) { (void)thiz; (void)method; }
|
||||||
|
static void hk_void1(void *thiz, void *arg, void *method) { (void)thiz; (void)arg; (void)method; }
|
||||||
|
|
||||||
|
static bool hk_toxmod_false(void *method) { (void)method; return false; }
|
||||||
|
|
||||||
|
static const char *cheat_reason(int r) {
|
||||||
|
switch (r) {
|
||||||
|
case 0: return "Obscured"; case 1: return "Time"; case 2: return "Inject";
|
||||||
|
case 3: return "GiftCount"; case 4: return "Engine"; case 5: return "UnknownDll";
|
||||||
|
case 6: return "ImageSignature"; case 7: return "AvatarHack";
|
||||||
|
case 100: return "NetworkCertificatePublicKey"; case 101: return "NetworkCertificateIssuer";
|
||||||
|
case 102: return "NetworkCertificateMissing"; case 103: return "NetworkCertificateMismatch";
|
||||||
|
case 150: return "AutosaveChecksumMismatch"; case 151: return "AutosaveSubRoomIdMismatch";
|
||||||
|
case 152: return "AutosaveChecksumException"; case 200: return "Photon_MissingHash";
|
||||||
|
case 201: return "Photon_CorruptHash"; case 203: return "Photon_DifferentHash";
|
||||||
|
case 300: return "AppData_Runtime_LengthMismatch"; case 301: return "AppData_Runtime_LastWriteTimeMismatch";
|
||||||
|
case 302: return "AppData_Runtime_FileModified"; case 310: return "AppData_Boot_InvalidSignature";
|
||||||
|
case 311: return "AppData_Boot_UnableToVerifySignatures"; case 320: return "Config_MissingHash";
|
||||||
|
case 321: return "Config_DifferentHash"; case 400: return "Photon_InstantiateTool";
|
||||||
|
case 500: return "Memory_Hash_Mismatch"; case 600: return "Driver_Invalid_Signature";
|
||||||
|
default: return "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void log_caller(const char *what, void *ret) {
|
||||||
|
uintptr_t a = (uintptr_t)ret;
|
||||||
|
if (g_il2cpp_base && a > g_il2cpp_base)
|
||||||
|
LOGI("[caller] %s <- libil2cpp rva 0x%lx", what, (unsigned long)(a - g_il2cpp_base));
|
||||||
|
else
|
||||||
|
LOGI("[caller] %s <- %p", what, ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void *(*orig_CheatReport)(int reason, void *msg, uint64_t code, void *method);
|
||||||
|
static void *hk_CheatReport(int reason, void *msg, uint64_t code, void *method) {
|
||||||
|
char text[512];
|
||||||
|
str_to_utf8(msg, text, sizeof(text));
|
||||||
|
LOGI("[integrity] report reason=%d (%s) msg=\"%s\"", reason, cheat_reason(reason), text);
|
||||||
|
log_caller("report", __builtin_return_address(0));
|
||||||
|
return orig_CheatReport(reason, msg, code, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void (*orig_FatalQuit)(int code, void *reason, void *method);
|
||||||
|
static void hk_FatalQuit(int code, void *reason, void *method) {
|
||||||
|
char msg[512];
|
||||||
|
str_to_utf8(reason, msg, sizeof(msg));
|
||||||
|
LOGI("[exit] FatalApplicationQuit(%d, \"%s\")", code, msg);
|
||||||
|
log_caller("fatal quit", __builtin_return_address(0));
|
||||||
|
orig_FatalQuit(code, reason, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void (*orig_TryQuit)(int code, void *method);
|
||||||
|
static void hk_TryQuit(int code, void *method) {
|
||||||
|
LOGI("[exit] TryApplicationQuit(%d)", code);
|
||||||
|
log_caller("try quit", __builtin_return_address(0));
|
||||||
|
orig_TryQuit(code, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- oculus platform -----
|
||||||
|
|
||||||
|
typedef bool (*ovr_IsError_t)(void *m);
|
||||||
|
typedef unsigned (*ovr_GetType_t)(void *m);
|
||||||
|
static ovr_IsError_t orig_ovr_IsError;
|
||||||
|
static ovr_GetType_t ovr_GetType;
|
||||||
|
|
||||||
|
static bool is_bypassed_type(unsigned t) {
|
||||||
|
return t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof ||
|
||||||
|
t == kMsgUserGetLoggedInUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SDK's Message constructor checks IsError twice: on the message, and --
|
||||||
|
// when that passes -- on ovr_Message_GetNativeMessage(message). The inner
|
||||||
|
// message carries the platform's refusal too but doesn't report the outer
|
||||||
|
// type, so remember the inner handle of a bypassed message (same thread, the
|
||||||
|
// constructor asks about it immediately) and pass it as well.
|
||||||
|
static __thread void *t_bypassed_native;
|
||||||
|
static __thread unsigned t_bypassed_type;
|
||||||
|
|
||||||
|
static bool hk_ovr_IsError(void *m) {
|
||||||
|
bool real = orig_ovr_IsError(m);
|
||||||
|
if (!real) return real;
|
||||||
|
if (m && m == t_bypassed_native) return false;
|
||||||
|
if (ovr_GetType && is_bypassed_type(ovr_GetType(m))) return false;
|
||||||
|
return real;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef void *(*ovr_GetNativeMessage_t)(void *m);
|
||||||
|
static ovr_GetNativeMessage_t orig_ovr_GetNativeMessage;
|
||||||
|
static void *hk_ovr_GetNativeMessage(void *m) {
|
||||||
|
void *native = orig_ovr_GetNativeMessage(m);
|
||||||
|
unsigned t = ovr_GetType ? ovr_GetType(m) : 0;
|
||||||
|
t_bypassed_native = is_bypassed_type(t) ? native : nullptr;
|
||||||
|
t_bypassed_type = t;
|
||||||
|
return native;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OculusPlatformManager.LoadUser asks for the logged-in user; on a headset
|
||||||
|
// that reaches Meta, Meta refuses it for this re-packaged app, LoadUser
|
||||||
|
// rejects ("GetLoggedInUser failed") and Initialize shows "Unable to load
|
||||||
|
// Oculus user account". A headset that can't reach Meta gets a cached user
|
||||||
|
// and never hits this. Answer a refused request with a placeholder user whose
|
||||||
|
// ID is kNoPlatformUserId -- the same placeholder the server already treats
|
||||||
|
// as "no platform account" (PlayerService.IsPlaceholderPlatformId).
|
||||||
|
static void *(*orig_ovr_Message_GetUser)(void *m);
|
||||||
|
static void *hk_ovr_Message_GetUser(void *m) {
|
||||||
|
void *u = orig_ovr_Message_GetUser ? orig_ovr_Message_GetUser(m) : nullptr;
|
||||||
|
if (u) return u;
|
||||||
|
// The SDK reads the user from the inner native message
|
||||||
|
// (GetUser(GetNativeMessage(msg))), so match that handle as well.
|
||||||
|
bool logged_in_user = (m && m == t_bypassed_native && t_bypassed_type == kMsgUserGetLoggedInUser) ||
|
||||||
|
(ovr_GetType && ovr_GetType(m) == kMsgUserGetLoggedInUser);
|
||||||
|
if (!logged_in_user) return u;
|
||||||
|
LOGI("GetLoggedInUser refused by the platform; using the no-platform placeholder user");
|
||||||
|
return kPlaceholderUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accessors the SDK's User model reads. Real users pass straight through; the
|
||||||
|
// placeholder gets empty strings and zero values.
|
||||||
|
#define DN_USER_STR_ACCESSOR(name) static const char *(*orig_##name)(void *u); static const char *hk_##name(void *u) { if (u == kPlaceholderUser) return ""; return orig_##name ? orig_##name(u) : ""; }
|
||||||
|
#define DN_USER_VAL_ACCESSOR(name) static uintptr_t (*orig_##name)(void *u); static uintptr_t hk_##name(void *u) { if (u == kPlaceholderUser) return 0; return orig_##name ? orig_##name(u) : 0; }
|
||||||
|
DN_USER_STR_ACCESSOR(ovr_User_GetDisplayName)
|
||||||
|
DN_USER_STR_ACCESSOR(ovr_User_GetOculusID)
|
||||||
|
DN_USER_STR_ACCESSOR(ovr_User_GetImageUrl)
|
||||||
|
DN_USER_STR_ACCESSOR(ovr_User_GetSmallImageUrl)
|
||||||
|
DN_USER_STR_ACCESSOR(ovr_User_GetInviteToken)
|
||||||
|
DN_USER_STR_ACCESSOR(ovr_User_GetPresence)
|
||||||
|
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDeeplinkMessage)
|
||||||
|
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDestinationApiName)
|
||||||
|
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceLobbySessionId)
|
||||||
|
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceMatchSessionId)
|
||||||
|
DN_USER_VAL_ACCESSOR(ovr_User_GetPresenceStatus) // ovrUserPresenceStatus: 0 = Unknown
|
||||||
|
|
||||||
|
static void *(*orig_ovr_GetUserProof)(void *m);
|
||||||
|
static void *hk_ovr_GetUserProof(void *m) {
|
||||||
|
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
|
||||||
|
return p ? p : (void *)0x1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *(*orig_ovr_GetNonce)(void *proof);
|
||||||
|
static const char *hk_ovr_GetNonce(void *proof) {
|
||||||
|
if (proof && proof != (void *)0x1 && orig_ovr_GetNonce) {
|
||||||
|
const char *n = orig_ovr_GetNonce(proof);
|
||||||
|
if (n && n[0]) return n;
|
||||||
|
}
|
||||||
|
return kPlaceholderNonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned long long (*orig_ovr_GetLoggedInUserID)();
|
||||||
|
static unsigned long long hk_ovr_GetLoggedInUserID() {
|
||||||
|
unsigned long long real = orig_ovr_GetLoggedInUserID ? orig_ovr_GetLoggedInUserID() : 0;
|
||||||
|
return real ? real : kNoPlatformUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned long long (*orig_ovr_User_GetID)(void *u);
|
||||||
|
static unsigned long long hk_ovr_User_GetID(void *u) {
|
||||||
|
if (u == kPlaceholderUser) return kNoPlatformUserId;
|
||||||
|
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
|
||||||
|
return real ? real : kNoPlatformUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ title screen --
|
||||||
|
|
||||||
|
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time auto-login:
|
||||||
|
// the title screen downloads the headset's remembered accounts and logs into
|
||||||
|
// one; with none (null) it creates a brand-new account instead
|
||||||
|
// (PJMGFADOEEM -> grant_type create_account). A server with sign-ups off
|
||||||
|
// refuses that, the error handler shows the connection-error page and the
|
||||||
|
// flow tries again, forever. Unless AutoCreateAccount is on, open the sign-in
|
||||||
|
// page instead (TitleScreenFlowModel.GoToLogin, what the "Log in" button
|
||||||
|
// does). ManuallyLogin carries the player into the game from there on its own,
|
||||||
|
// so the boot step gets a promise that never settles.
|
||||||
|
static void *(*orig_TitleAutoLogin)(void *thiz, void *cached, void *method);
|
||||||
|
static void *new_pending_promise() {
|
||||||
|
// MAMJDBJDFAF: the game's non-generic promise (RecRoom.Promises.Runtime);
|
||||||
|
// a fresh one stays pending.
|
||||||
|
static void *cls = find_class("RecRoom.Promises.Runtime.dll", "", "MAMJDBJDFAF");
|
||||||
|
if (!cls) return nullptr;
|
||||||
|
void *ctor = il2cpp_class_get_method_from_name(cls, ".ctor", 0);
|
||||||
|
if (!ctor) return nullptr;
|
||||||
|
void *p = il2cpp_object_new(cls);
|
||||||
|
void *exc = nullptr;
|
||||||
|
if (p) il2cpp_runtime_invoke(ctor, p, nullptr, &exc);
|
||||||
|
return exc ? nullptr : p;
|
||||||
|
}
|
||||||
|
static void *hk_TitleAutoLogin(void *thiz, void *cached, void *method) {
|
||||||
|
if (cached || g_auto_create_account) return orig_TitleAutoLogin(thiz, cached, method);
|
||||||
|
void *pending = new_pending_promise();
|
||||||
|
if (!pending || !invoke_void(thiz, "GoToLogin")) {
|
||||||
|
LOGE("no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
|
||||||
|
return orig_TitleAutoLogin(thiz, cached, method);
|
||||||
|
}
|
||||||
|
LOGI("no remembered account on this headset; opened the sign-in page instead of creating a new account");
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- init ------
|
||||||
|
|
||||||
|
static uintptr_t module_base(const char *name) {
|
||||||
|
FILE *f = fopen("/proc/self/maps", "r");
|
||||||
|
if (!f) return 0;
|
||||||
|
char line[512];
|
||||||
|
uintptr_t base = 0;
|
||||||
|
while (fgets(line, sizeof(line), f))
|
||||||
|
if (strstr(line, name)) { base = (uintptr_t)strtoull(line, nullptr, 16); break; }
|
||||||
|
fclose(f);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint64_t fnv1a(const unsigned char *d, size_t n) {
|
||||||
|
uint64_t h = 0xCBF29CE484222325ULL;
|
||||||
|
for (size_t i = 0; i < n; i++) { h ^= d[i]; h *= 0x100000001B3ULL; }
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arm a target by id: verify the 20-byte fingerprint, then install the hook.
|
||||||
|
static bool arm(const DnTarget *t, void *hook, void **orig) {
|
||||||
|
void *addr = (void *)(g_il2cpp_base + t->rva);
|
||||||
|
uint64_t fp = fnv1a((const unsigned char *)addr, DN_HOOK_BYTES);
|
||||||
|
if (fp != t->fingerprint) {
|
||||||
|
LOGE("REFUSING %s: rva 0x%lx fingerprint 0x%llx != expected 0x%llx (wrong build?)",
|
||||||
|
t->id, (unsigned long)t->rva, (unsigned long long)fp,
|
||||||
|
(unsigned long long)t->fingerprint);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
void *dummy = nullptr;
|
||||||
|
A64HookFunction(addr, hook, orig ? orig : &dummy);
|
||||||
|
LOGI("armed %s @ rva 0x%lx", t->id, (unsigned long)t->rva);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool resolve_il2cpp(void *h) {
|
||||||
|
#define SYM(n) n = (n##_t)dlsym(h, #n); if (!n) { LOGE("dlsym " #n " failed"); return false; }
|
||||||
|
SYM(il2cpp_object_get_class)
|
||||||
|
SYM(il2cpp_class_get_method_from_name)
|
||||||
|
SYM(il2cpp_object_new)
|
||||||
|
SYM(il2cpp_string_new)
|
||||||
|
SYM(il2cpp_runtime_invoke)
|
||||||
|
#undef SYM
|
||||||
|
// Optional: only the title-screen sign-in redirect needs these.
|
||||||
|
il2cpp_domain_get = (il2cpp_domain_get_t)dlsym(h, "il2cpp_domain_get");
|
||||||
|
il2cpp_domain_get_assemblies = (il2cpp_domain_get_assemblies_t)dlsym(h, "il2cpp_domain_get_assemblies");
|
||||||
|
il2cpp_assembly_get_image = (il2cpp_assembly_get_image_t)dlsym(h, "il2cpp_assembly_get_image");
|
||||||
|
il2cpp_image_get_name = (il2cpp_image_get_name_t)dlsym(h, "il2cpp_image_get_name");
|
||||||
|
il2cpp_class_from_name = (il2cpp_class_from_name_t)dlsym(h, "il2cpp_class_from_name");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void arm_oculus_platform_bypass() {
|
||||||
|
void *ovr = nullptr;
|
||||||
|
for (int i = 0; i < 600; i++) {
|
||||||
|
ovr = dlopen("libovrplatformloader.so", RTLD_NOLOAD | RTLD_NOW);
|
||||||
|
if (ovr) break;
|
||||||
|
usleep(100 * 1000);
|
||||||
|
}
|
||||||
|
if (!ovr) { LOGE("libovrplatformloader.so never appeared; platform bypass not armed"); return; }
|
||||||
|
ovr_GetType = (ovr_GetType_t)dlsym(ovr, "ovr_Message_GetType");
|
||||||
|
void *isErr = dlsym(ovr, "ovr_Message_IsError");
|
||||||
|
if (ovr_GetType && isErr) {
|
||||||
|
A64HookFunction(isErr, (void *)hk_ovr_IsError, (void **)&orig_ovr_IsError);
|
||||||
|
LOGI("platform entitlement/proof bypass armed (no Meta app id configured)");
|
||||||
|
} else {
|
||||||
|
LOGE("ovr symbols missing; platform bypass not armed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void *a;
|
||||||
|
if ((a = dlsym(ovr, "ovr_Message_GetNativeMessage")))
|
||||||
|
A64HookFunction(a, (void *)hk_ovr_GetNativeMessage, (void **)&orig_ovr_GetNativeMessage);
|
||||||
|
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
|
||||||
|
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
|
||||||
|
if ((a = dlsym(ovr, "ovr_User_GetID")))
|
||||||
|
A64HookFunction(a, (void *)hk_ovr_User_GetID, (void **)&orig_ovr_User_GetID);
|
||||||
|
if ((a = dlsym(ovr, "ovr_Message_GetUserProof")))
|
||||||
|
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
|
||||||
|
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
|
||||||
|
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
|
||||||
|
if ((a = dlsym(ovr, "ovr_Message_GetUser")))
|
||||||
|
A64HookFunction(a, (void *)hk_ovr_Message_GetUser, (void **)&orig_ovr_Message_GetUser);
|
||||||
|
#define DN_HOOK_USER(name) if ((a = dlsym(ovr, #name))) A64HookFunction(a, (void *)hk_##name, (void **)&orig_##name);
|
||||||
|
DN_HOOK_USER(ovr_User_GetDisplayName)
|
||||||
|
DN_HOOK_USER(ovr_User_GetOculusID)
|
||||||
|
DN_HOOK_USER(ovr_User_GetImageUrl)
|
||||||
|
DN_HOOK_USER(ovr_User_GetSmallImageUrl)
|
||||||
|
DN_HOOK_USER(ovr_User_GetInviteToken)
|
||||||
|
DN_HOOK_USER(ovr_User_GetPresence)
|
||||||
|
DN_HOOK_USER(ovr_User_GetPresenceDeeplinkMessage)
|
||||||
|
DN_HOOK_USER(ovr_User_GetPresenceDestinationApiName)
|
||||||
|
DN_HOOK_USER(ovr_User_GetPresenceLobbySessionId)
|
||||||
|
DN_HOOK_USER(ovr_User_GetPresenceMatchSessionId)
|
||||||
|
DN_HOOK_USER(ovr_User_GetPresenceStatus)
|
||||||
|
#undef DN_HOOK_USER
|
||||||
|
}
|
||||||
|
|
||||||
|
static void *init_thread(void *) {
|
||||||
|
void *h = nullptr;
|
||||||
|
for (int i = 0; i < 600; i++) {
|
||||||
|
h = dlopen("libil2cpp.so", RTLD_NOLOAD | RTLD_NOW);
|
||||||
|
if (h) break;
|
||||||
|
usleep(100 * 1000);
|
||||||
|
}
|
||||||
|
if (!h) { LOGE("libil2cpp.so never appeared; aborting"); return nullptr; }
|
||||||
|
g_il2cpp_base = module_base("libil2cpp.so");
|
||||||
|
if (!g_il2cpp_base) { LOGE("could not find libil2cpp.so base"); return nullptr; }
|
||||||
|
if (!resolve_il2cpp(h)) return nullptr;
|
||||||
|
dn_load_runtime_config(g_server_host, sizeof(g_server_host), g_oculus_appid, sizeof(g_oculus_appid),
|
||||||
|
g_photon_realtime, g_photon_voice, g_photon_chat,
|
||||||
|
g_photon_server, sizeof(g_photon_server), &g_photon_port,
|
||||||
|
g_photon_region, sizeof(g_photon_region),
|
||||||
|
&g_tls_bypass, &g_accept_images, &g_auto_create_account, &g_log_http, &g_debug_http, &g_body_max);
|
||||||
|
|
||||||
|
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
|
||||||
|
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
|
||||||
|
|
||||||
|
arm(&dn_targets.http_send_request, (void *)hk_SendRequest, (void **)&orig_SendRequest);
|
||||||
|
if (g_log_http)
|
||||||
|
arm(&dn_targets.http_call_callback, (void *)hk_CallCallback, (void **)&orig_CallCallback);
|
||||||
|
arm(&dn_targets.photon_app_settings, (void *)hk_Photon, (void **)&orig_Photon);
|
||||||
|
|
||||||
|
if (g_oculus_appid[0]) {
|
||||||
|
arm(&dn_targets.oculus_get_app_id, (void *)hk_get_AppID, nullptr);
|
||||||
|
arm(&dn_targets.oculus_get_mobile_app_id, (void *)hk_get_AppID, nullptr);
|
||||||
|
LOGI("oculus app id override: %s", g_oculus_appid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_tls_bypass) {
|
||||||
|
arm(&dn_targets.tls_unity_verify, (void *)hk_UnityTls, (void **)&orig_UnityTls);
|
||||||
|
arm(&dn_targets.tls_bc_notify_server_certificate, (void *)hk_NotifyServerCertificate, nullptr);
|
||||||
|
arm(&dn_targets.pin_nameserver, (void *)hk_pin_true4, nullptr);
|
||||||
|
arm(&dn_targets.pin_https, (void *)hk_pin_true4b, nullptr);
|
||||||
|
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
arm(&dn_targets.title_auto_login, (void *)hk_TitleAutoLogin, (void **)&orig_TitleAutoLogin);
|
||||||
|
|
||||||
|
if (g_accept_images)
|
||||||
|
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
|
||||||
|
|
||||||
|
// Integrity callbacks that would reject a re-signed, hook-loaded client.
|
||||||
|
arm(&dn_targets.hile_warning_callback, (void *)hk_void1, nullptr);
|
||||||
|
arm(&dn_targets.unknown_dll_callback, (void *)hk_void0, nullptr);
|
||||||
|
arm(&dn_targets.file_hash_callback, (void *)hk_void1, nullptr);
|
||||||
|
arm(&dn_targets.toxmod_can_initialize, (void *)hk_toxmod_false, nullptr);
|
||||||
|
|
||||||
|
// Log-only diagnostics.
|
||||||
|
arm(&dn_targets.cheat_report, (void *)hk_CheatReport, (void **)&orig_CheatReport);
|
||||||
|
arm(&dn_targets.fatal_application_quit, (void *)hk_FatalQuit, (void **)&orig_FatalQuit);
|
||||||
|
arm(&dn_targets.try_application_quit, (void *)hk_TryQuit, (void **)&orig_TryQuit);
|
||||||
|
|
||||||
|
if (!g_oculus_appid[0]) arm_oculus_platform_bypass();
|
||||||
|
LOGI("DorkNet.Quest init complete");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
__attribute__((constructor)) static void on_load() {
|
||||||
|
LOGI("DorkNet.Quest hooks loaded (build " __DATE__ " " __TIME__ ")");
|
||||||
|
pthread_t t;
|
||||||
|
pthread_create(&t, nullptr, init_thread, nullptr);
|
||||||
|
pthread_detach(t);
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
/*
|
||||||
|
* @date : 2018/04/18
|
||||||
|
* @author : Rprop (r_prop@outlook.com)
|
||||||
|
* https://github.com/Rprop/And64InlineHook
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2018 Rprop (r_prop@outlook.com)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
*/
|
||||||
|
#define __STDC_FORMAT_MACROS
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
|
||||||
|
#if defined(__aarch64__)
|
||||||
|
|
||||||
|
#include "And64InlineHook.hpp"
|
||||||
|
#define A64_MAX_INSTRUCTIONS 5
|
||||||
|
#define A64_MAX_REFERENCES (A64_MAX_INSTRUCTIONS * 2)
|
||||||
|
#define A64_NOP 0xd503201fu
|
||||||
|
#define A64_JNIEXPORT __attribute__((visibility("default")))
|
||||||
|
#define A64_LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, "A64_HOOK", __VA_ARGS__))
|
||||||
|
#ifndef NDEBUG
|
||||||
|
# define A64_LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "A64_HOOK", __VA_ARGS__))
|
||||||
|
#else
|
||||||
|
# define A64_LOGI(...) ((void)0)
|
||||||
|
#endif // NDEBUG
|
||||||
|
typedef uint32_t *__restrict *__restrict instruction;
|
||||||
|
struct context
|
||||||
|
{
|
||||||
|
struct fix_info
|
||||||
|
{
|
||||||
|
uint32_t *bp;
|
||||||
|
uint32_t ls; // left-shift counts
|
||||||
|
uint32_t ad; // & operand
|
||||||
|
};
|
||||||
|
struct insns_info
|
||||||
|
{
|
||||||
|
union
|
||||||
|
{
|
||||||
|
uint64_t insu;
|
||||||
|
int64_t ins;
|
||||||
|
void *insp;
|
||||||
|
};
|
||||||
|
fix_info fmap[A64_MAX_REFERENCES];
|
||||||
|
};
|
||||||
|
int64_t basep;
|
||||||
|
int64_t endp;
|
||||||
|
insns_info dat[A64_MAX_INSTRUCTIONS];
|
||||||
|
|
||||||
|
public:
|
||||||
|
inline bool is_in_fixing_range(const int64_t absolute_addr) {
|
||||||
|
return absolute_addr >= this->basep && absolute_addr < this->endp;
|
||||||
|
}
|
||||||
|
inline intptr_t get_ref_ins_index(const int64_t absolute_addr) {
|
||||||
|
return static_cast<intptr_t>((absolute_addr - this->basep) / sizeof(uint32_t));
|
||||||
|
}
|
||||||
|
inline intptr_t get_and_set_current_index(uint32_t *__restrict inp, uint32_t *__restrict outp) {
|
||||||
|
intptr_t current_idx = this->get_ref_ins_index(reinterpret_cast<int64_t>(inp));
|
||||||
|
this->dat[current_idx].insp = outp;
|
||||||
|
return current_idx;
|
||||||
|
}
|
||||||
|
inline void reset_current_ins(const intptr_t idx, uint32_t *__restrict outp) {
|
||||||
|
this->dat[idx].insp = outp;
|
||||||
|
}
|
||||||
|
void insert_fix_map(const intptr_t idx, uint32_t *bp, uint32_t ls = 0u, uint32_t ad = 0xffffffffu) {
|
||||||
|
for (auto &f : this->dat[idx].fmap) {
|
||||||
|
if (f.bp == NULL) {
|
||||||
|
f.bp = bp;
|
||||||
|
f.ls = ls;
|
||||||
|
f.ad = ad;
|
||||||
|
return;
|
||||||
|
} //if
|
||||||
|
}
|
||||||
|
// What? GGing..
|
||||||
|
}
|
||||||
|
void process_fix_map(const intptr_t idx) {
|
||||||
|
for (auto &f : this->dat[idx].fmap) {
|
||||||
|
if (f.bp == NULL) break;
|
||||||
|
*(f.bp) = *(f.bp) | (((int32_t(this->dat[idx].ins - reinterpret_cast<int64_t>(f.bp)) >> 2) << f.ls) & f.ad);
|
||||||
|
f.bp = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#define __intval(p) reinterpret_cast<intptr_t>(p)
|
||||||
|
#define __uintval(p) reinterpret_cast<uintptr_t>(p)
|
||||||
|
#define __ptr(p) reinterpret_cast<void *>(p)
|
||||||
|
#define __page_size 4096
|
||||||
|
#define __page_align(n) __align_up(static_cast<uintptr_t>(n), __page_size)
|
||||||
|
#define __ptr_align(x) __ptr(__align_down(reinterpret_cast<uintptr_t>(x), __page_size))
|
||||||
|
#define __align_up(x, n) (((x) + ((n) - 1)) & ~((n) - 1))
|
||||||
|
#define __align_down(x, n) ((x) & -(n))
|
||||||
|
#define __countof(x) static_cast<intptr_t>(sizeof(x) / sizeof((x)[0])) // must be signed
|
||||||
|
#define __atomic_increase(p) __sync_add_and_fetch(p, 1)
|
||||||
|
#define __sync_cmpswap(p, v, n) __sync_bool_compare_and_swap(p, v, n)
|
||||||
|
#define __predict_true(exp) __builtin_expect((exp) != 0, 1)
|
||||||
|
#define __flush_cache(c, n) __builtin___clear_cache(reinterpret_cast<char *>(c), reinterpret_cast<char *>(c) + n)
|
||||||
|
#define __make_rwx(p, n) ::mprotect(__ptr_align(p), \
|
||||||
|
__page_align(__uintval(p) + n) != __page_align(__uintval(p)) ? __page_align(n) + __page_size : __page_align(n), \
|
||||||
|
PROT_READ | PROT_WRITE | PROT_EXEC)
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static bool __fix_branch_imm(instruction inpp, instruction outpp, context *ctxp)
|
||||||
|
{
|
||||||
|
static constexpr uint32_t mbits = 6u;
|
||||||
|
static constexpr uint32_t mask = 0xfc000000u; // 0b11111100000000000000000000000000
|
||||||
|
static constexpr uint32_t rmask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||||
|
static constexpr uint32_t op_b = 0x14000000u; // "b" ADDR_PCREL26
|
||||||
|
static constexpr uint32_t op_bl = 0x94000000u; // "bl" ADDR_PCREL26
|
||||||
|
|
||||||
|
const uint32_t ins = *(*inpp);
|
||||||
|
const uint32_t opc = ins & mask;
|
||||||
|
switch (opc) {
|
||||||
|
case op_b:
|
||||||
|
case op_bl:
|
||||||
|
{
|
||||||
|
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||||
|
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>(ins << mbits) >> (mbits - 2u)); // sign-extended
|
||||||
|
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||||
|
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||||
|
// whether the branch should be converted to absolute jump
|
||||||
|
if (!special_fix_type && llabs(new_pc_offset) >= (rmask >> 1)) {
|
||||||
|
bool b_aligned = (reinterpret_cast<uint64_t>(*outpp + 2) & 7u) == 0u;
|
||||||
|
if (opc == op_b) {
|
||||||
|
if (b_aligned != true) {
|
||||||
|
(*outpp)[0] = A64_NOP;
|
||||||
|
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||||
|
} //if
|
||||||
|
(*outpp)[0] = 0x58000051u; // LDR X17, #0x8
|
||||||
|
(*outpp)[1] = 0xd61f0220u; // BR X17
|
||||||
|
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
|
||||||
|
*outpp += 4;
|
||||||
|
} else {
|
||||||
|
if (b_aligned == true) {
|
||||||
|
(*outpp)[0] = A64_NOP;
|
||||||
|
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||||
|
} //if
|
||||||
|
(*outpp)[0] = 0x58000071u; // LDR X17, #12
|
||||||
|
(*outpp)[1] = 0x1000009eu; // ADR X30, #16
|
||||||
|
(*outpp)[2] = 0xd61f0220u; // BR X17
|
||||||
|
memcpy(*outpp + 3, &absolute_addr, sizeof(absolute_addr));
|
||||||
|
*outpp += 5;
|
||||||
|
} //if
|
||||||
|
} else {
|
||||||
|
if (special_fix_type) {
|
||||||
|
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
|
||||||
|
if (ref_idx <= current_idx) {
|
||||||
|
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||||
|
} else {
|
||||||
|
ctxp->insert_fix_map(ref_idx, *outpp, 0u, rmask);
|
||||||
|
new_pc_offset = 0;
|
||||||
|
} //if
|
||||||
|
} //if
|
||||||
|
|
||||||
|
(*outpp)[0] = opc | (new_pc_offset & ~mask);
|
||||||
|
++(*outpp);
|
||||||
|
} //if
|
||||||
|
|
||||||
|
++(*inpp);
|
||||||
|
return ctxp->process_fix_map(current_idx), true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static bool __fix_cond_comp_test_branch(instruction inpp, instruction outpp, context *ctxp)
|
||||||
|
{
|
||||||
|
static constexpr uint32_t lsb = 5u;
|
||||||
|
static constexpr uint32_t lmask01 = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||||
|
static constexpr uint32_t mask0 = 0xff000010u; // 0b11111111000000000000000000010000
|
||||||
|
static constexpr uint32_t op_bc = 0x54000000u; // "b.c" ADDR_PCREL19
|
||||||
|
static constexpr uint32_t mask1 = 0x7f000000u; // 0b01111111000000000000000000000000
|
||||||
|
static constexpr uint32_t op_cbz = 0x34000000u; // "cbz" Rt, ADDR_PCREL19
|
||||||
|
static constexpr uint32_t op_cbnz = 0x35000000u; // "cbnz" Rt, ADDR_PCREL19
|
||||||
|
static constexpr uint32_t lmask2 = 0xfff8001fu; // 0b11111111111110000000000000011111
|
||||||
|
static constexpr uint32_t mask2 = 0x7f000000u; // 0b01111111000000000000000000000000
|
||||||
|
static constexpr uint32_t op_tbz = 0x36000000u; // 0b00110110000000000000000000000000 "tbz" Rt, BIT_NUM, ADDR_PCREL14
|
||||||
|
static constexpr uint32_t op_tbnz = 0x37000000u; // 0b00110111000000000000000000000000 "tbnz" Rt, BIT_NUM, ADDR_PCREL14
|
||||||
|
|
||||||
|
const uint32_t ins = *(*inpp);
|
||||||
|
uint32_t lmask = lmask01;
|
||||||
|
if ((ins & mask0) != op_bc) {
|
||||||
|
uint32_t opc = ins & mask1;
|
||||||
|
if (opc != op_cbz && opc != op_cbnz) {
|
||||||
|
opc = ins & mask2;
|
||||||
|
if (opc != op_tbz && opc != op_tbnz) {
|
||||||
|
return false;
|
||||||
|
} //if
|
||||||
|
lmask = lmask2;
|
||||||
|
} //if
|
||||||
|
} //if
|
||||||
|
|
||||||
|
const uint32_t msb = __builtin_clz(~lmask);
|
||||||
|
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||||
|
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>((ins & ~lmask) << msb) >> (lsb - 2u + msb));
|
||||||
|
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||||
|
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||||
|
if (!special_fix_type && llabs(new_pc_offset) >= (~lmask >> (lsb + 1))) {
|
||||||
|
if ((reinterpret_cast<uint64_t>(*outpp + 4) & 7u) != 0u) {
|
||||||
|
(*outpp)[0] = A64_NOP;
|
||||||
|
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||||
|
} //if
|
||||||
|
(*outpp)[0] = (((8u >> 2u) << lsb) & ~lmask) | (ins & lmask); // B.C #0x8
|
||||||
|
(*outpp)[1] = 0x14000005u; // B #0x14
|
||||||
|
(*outpp)[2] = 0x58000051u; // LDR X17, #0x8
|
||||||
|
(*outpp)[3] = 0xd61f0220u; // BR X17
|
||||||
|
memcpy(*outpp + 4, &absolute_addr, sizeof(absolute_addr));
|
||||||
|
*outpp += 6;
|
||||||
|
} else {
|
||||||
|
if (special_fix_type) {
|
||||||
|
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
|
||||||
|
if (ref_idx <= current_idx) {
|
||||||
|
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||||
|
} else {
|
||||||
|
ctxp->insert_fix_map(ref_idx, *outpp, lsb, ~lmask);
|
||||||
|
new_pc_offset = 0;
|
||||||
|
} //if
|
||||||
|
} //if
|
||||||
|
|
||||||
|
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~lmask) | (ins & lmask);
|
||||||
|
++(*outpp);
|
||||||
|
} //if
|
||||||
|
|
||||||
|
++(*inpp);
|
||||||
|
return ctxp->process_fix_map(current_idx), true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static bool __fix_loadlit(instruction inpp, instruction outpp, context *ctxp)
|
||||||
|
{
|
||||||
|
const uint32_t ins = *(*inpp);
|
||||||
|
|
||||||
|
// memory prefetch("prfm"), just skip it
|
||||||
|
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897420050.html
|
||||||
|
if ((ins & 0xff000000u) == 0xd8000000u) {
|
||||||
|
ctxp->process_fix_map(ctxp->get_and_set_current_index(*inpp, *outpp));
|
||||||
|
++(*inpp);
|
||||||
|
return true;
|
||||||
|
} //if
|
||||||
|
|
||||||
|
static constexpr uint32_t msb = 8u;
|
||||||
|
static constexpr uint32_t lsb = 5u;
|
||||||
|
static constexpr uint32_t mask_30 = 0x40000000u; // 0b01000000000000000000000000000000
|
||||||
|
static constexpr uint32_t mask_31 = 0x80000000u; // 0b10000000000000000000000000000000
|
||||||
|
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||||
|
static constexpr uint32_t mask_ldr = 0xbf000000u; // 0b10111111000000000000000000000000
|
||||||
|
static constexpr uint32_t op_ldr = 0x18000000u; // 0b00011000000000000000000000000000 "LDR Wt/Xt, label" | ADDR_PCREL19
|
||||||
|
static constexpr uint32_t mask_ldrv = 0x3f000000u; // 0b00111111000000000000000000000000
|
||||||
|
static constexpr uint32_t op_ldrv = 0x1c000000u; // 0b00011100000000000000000000000000 "LDR St/Dt/Qt, label" | ADDR_PCREL19
|
||||||
|
static constexpr uint32_t mask_ldrsw = 0xff000000u; // 0b11111111000000000000000000000000
|
||||||
|
static constexpr uint32_t op_ldrsw = 0x98000000u; // "LDRSW Xt, label" | ADDR_PCREL19 | load register signed word
|
||||||
|
// LDR S0, #0 | 0b00011100000000000000000000000000 | 32-bit
|
||||||
|
// LDR D0, #0 | 0b01011100000000000000000000000000 | 64-bit
|
||||||
|
// LDR Q0, #0 | 0b10011100000000000000000000000000 | 128-bit
|
||||||
|
// INVALID | 0b11011100000000000000000000000000 | may be 256-bit
|
||||||
|
|
||||||
|
uint32_t mask = mask_ldr;
|
||||||
|
uintptr_t faligned = (ins & mask_30) ? 7u : 3u;
|
||||||
|
if ((ins & mask_ldr) != op_ldr) {
|
||||||
|
mask = mask_ldrv;
|
||||||
|
if (faligned != 7u)
|
||||||
|
faligned = (ins & mask_31) ? 15u : 3u;
|
||||||
|
if ((ins & mask_ldrv) != op_ldrv) {
|
||||||
|
if ((ins & mask_ldrsw) != op_ldrsw) {
|
||||||
|
return false;
|
||||||
|
} //if
|
||||||
|
mask = mask_ldrsw;
|
||||||
|
faligned = 7u;
|
||||||
|
} //if
|
||||||
|
} //if
|
||||||
|
|
||||||
|
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||||
|
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + ((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u);
|
||||||
|
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||||
|
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||||
|
// special_fix_type may encounter issue when there are mixed data and code
|
||||||
|
if (special_fix_type || (llabs(new_pc_offset) + (faligned + 1u - 4u) / 4u) >= (~lmask >> (lsb + 1))) { // inaccurate, but it works
|
||||||
|
while ((reinterpret_cast<uint64_t>(*outpp + 2) & faligned) != 0u) {
|
||||||
|
*(*outpp)++ = A64_NOP;
|
||||||
|
}
|
||||||
|
ctxp->reset_current_ins(current_idx, *outpp);
|
||||||
|
|
||||||
|
// Note that if memory at absolute_addr is writeable (non-const), we will fail to fetch it.
|
||||||
|
// And what's worse, we may unexpectedly overwrite something if special_fix_type is true...
|
||||||
|
uint32_t ns = static_cast<uint32_t>((faligned + 1) / sizeof(uint32_t));
|
||||||
|
(*outpp)[0] = (((8u >> 2u) << lsb) & ~mask) | (ins & lmask); // LDR #0x8
|
||||||
|
(*outpp)[1] = 0x14000001u + ns; // B #0xc
|
||||||
|
memcpy(*outpp + 2, reinterpret_cast<void *>(absolute_addr), faligned + 1);
|
||||||
|
*outpp += 2 + ns;
|
||||||
|
} else {
|
||||||
|
faligned >>= 2; // new_pc_offset is shifted and 4-byte aligned
|
||||||
|
while ((new_pc_offset & faligned) != 0) {
|
||||||
|
*(*outpp)++ = A64_NOP;
|
||||||
|
new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||||
|
}
|
||||||
|
ctxp->reset_current_ins(current_idx, *outpp);
|
||||||
|
|
||||||
|
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~mask) | (ins & lmask);
|
||||||
|
++(*outpp);
|
||||||
|
} //if
|
||||||
|
|
||||||
|
++(*inpp);
|
||||||
|
return ctxp->process_fix_map(current_idx), true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static bool __fix_pcreladdr(instruction inpp, instruction outpp, context *ctxp)
|
||||||
|
{
|
||||||
|
// Load a PC-relative address into a register
|
||||||
|
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897645644.html
|
||||||
|
static constexpr uint32_t msb = 8u;
|
||||||
|
static constexpr uint32_t lsb = 5u;
|
||||||
|
static constexpr uint32_t mask = 0x9f000000u; // 0b10011111000000000000000000000000
|
||||||
|
static constexpr uint32_t rmask = 0x0000001fu; // 0b00000000000000000000000000011111
|
||||||
|
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||||
|
static constexpr uint32_t fmask = 0x00ffffffu; // 0b00000000111111111111111111111111
|
||||||
|
static constexpr uint32_t max_val = 0x001fffffu; // 0b00000000000111111111111111111111
|
||||||
|
static constexpr uint32_t op_adr = 0x10000000u; // "adr" Rd, ADDR_PCREL21
|
||||||
|
static constexpr uint32_t op_adrp = 0x90000000u; // "adrp" Rd, ADDR_ADRP
|
||||||
|
|
||||||
|
const uint32_t ins = *(*inpp);
|
||||||
|
intptr_t current_idx;
|
||||||
|
switch (ins & mask) {
|
||||||
|
case op_adr:
|
||||||
|
{
|
||||||
|
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||||
|
int64_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
|
||||||
|
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes);
|
||||||
|
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp));
|
||||||
|
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||||
|
if (!special_fix_type && llabs(new_pc_offset) >= (max_val >> 1)) {
|
||||||
|
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
|
||||||
|
(*outpp)[0] = A64_NOP;
|
||||||
|
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||||
|
} //if
|
||||||
|
|
||||||
|
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
|
||||||
|
(*outpp)[1] = 0x14000003u; // B #0xc
|
||||||
|
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
|
||||||
|
*outpp += 4;
|
||||||
|
} else {
|
||||||
|
if (special_fix_type) {
|
||||||
|
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr & ~3ull);
|
||||||
|
if (ref_idx <= current_idx) {
|
||||||
|
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp));
|
||||||
|
} else {
|
||||||
|
ctxp->insert_fix_map(ref_idx, *outpp, lsb, fmask);
|
||||||
|
new_pc_offset = 0;
|
||||||
|
} //if
|
||||||
|
} //if
|
||||||
|
|
||||||
|
// the lsb_bytes will never be changed, so we can use lmask to keep it
|
||||||
|
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << (lsb - 2u)) & fmask) | (ins & lmask);
|
||||||
|
++(*outpp);
|
||||||
|
} //if
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case op_adrp:
|
||||||
|
{
|
||||||
|
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||||
|
int32_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
|
||||||
|
int64_t absolute_addr = (reinterpret_cast<int64_t>(*inpp) & ~0xfffll) + ((((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes) << 12);
|
||||||
|
A64_LOGI("ins = 0x%.8X, pc = %p, abs_addr = %p",
|
||||||
|
ins, *inpp, reinterpret_cast<int64_t *>(absolute_addr));
|
||||||
|
if (ctxp->is_in_fixing_range(absolute_addr)) {
|
||||||
|
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr/* & ~3ull*/);
|
||||||
|
if (ref_idx > current_idx) {
|
||||||
|
// the bottom 12 bits of absolute_addr are masked out,
|
||||||
|
// so ref_idx must be less than or equal to current_idx!
|
||||||
|
A64_LOGE("ref_idx must be less than or equal to current_idx!");
|
||||||
|
} //if
|
||||||
|
|
||||||
|
// *absolute_addr may be changed due to relocation fixing
|
||||||
|
A64_LOGI("What is the correct way to fix this?");
|
||||||
|
*(*outpp)++ = ins; // 0x90000000u;
|
||||||
|
} else {
|
||||||
|
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
|
||||||
|
(*outpp)[0] = A64_NOP;
|
||||||
|
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||||
|
} //if
|
||||||
|
|
||||||
|
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
|
||||||
|
(*outpp)[1] = 0x14000003u; // B #0xc
|
||||||
|
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr)); // potential overflow?
|
||||||
|
*outpp += 4;
|
||||||
|
} //if
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxp->process_fix_map(current_idx);
|
||||||
|
++(*inpp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void __fix_instructions(uint32_t *__restrict inp, int32_t count, uint32_t *__restrict outp)
|
||||||
|
{
|
||||||
|
context ctx;
|
||||||
|
ctx.basep = reinterpret_cast<int64_t>(inp);
|
||||||
|
ctx.endp = reinterpret_cast<int64_t>(inp + count);
|
||||||
|
memset(ctx.dat, 0, sizeof(ctx.dat));
|
||||||
|
static_assert(sizeof(ctx.dat) / sizeof(ctx.dat[0]) == A64_MAX_INSTRUCTIONS,
|
||||||
|
"please use A64_MAX_INSTRUCTIONS!");
|
||||||
|
#ifndef NDEBUG
|
||||||
|
if (count > A64_MAX_INSTRUCTIONS) {
|
||||||
|
A64_LOGE("too many fixing instructions!");
|
||||||
|
} //if
|
||||||
|
#endif // NDEBUG
|
||||||
|
|
||||||
|
uint32_t *const outp_base = outp;
|
||||||
|
|
||||||
|
while (--count >= 0) {
|
||||||
|
if (__fix_branch_imm(&inp, &outp, &ctx)) continue;
|
||||||
|
if (__fix_cond_comp_test_branch(&inp, &outp, &ctx)) continue;
|
||||||
|
if (__fix_loadlit(&inp, &outp, &ctx)) continue;
|
||||||
|
if (__fix_pcreladdr(&inp, &outp, &ctx)) continue;
|
||||||
|
|
||||||
|
// without PC-relative offset
|
||||||
|
ctx.process_fix_map(ctx.get_and_set_current_index(inp, outp));
|
||||||
|
*(outp++) = *(inp++);
|
||||||
|
}
|
||||||
|
|
||||||
|
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||||
|
auto callback = reinterpret_cast<int64_t>(inp);
|
||||||
|
auto pc_offset = static_cast<int64_t>(callback - reinterpret_cast<int64_t>(outp)) >> 2;
|
||||||
|
if (llabs(pc_offset) >= (mask >> 1)) {
|
||||||
|
if ((reinterpret_cast<uint64_t>(outp + 2) & 7u) != 0u) {
|
||||||
|
outp[0] = A64_NOP;
|
||||||
|
++outp;
|
||||||
|
} //if
|
||||||
|
outp[0] = 0x58000051u; // LDR X17, #0x8
|
||||||
|
outp[1] = 0xd61f0220u; // BR X17
|
||||||
|
*reinterpret_cast<int64_t *>(outp + 2) = callback;
|
||||||
|
outp += 4;
|
||||||
|
} else {
|
||||||
|
outp[0] = 0x14000000u | (pc_offset & mask); // "B" ADDR_PCREL26
|
||||||
|
++outp;
|
||||||
|
} //if
|
||||||
|
|
||||||
|
const uintptr_t total = (outp - outp_base) * sizeof(uint32_t);
|
||||||
|
__flush_cache(outp_base, total); // necessary
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
static __attribute__((__aligned__(__page_size))) uint32_t __insns_pool[A64_MAX_BACKUPS][A64_MAX_INSTRUCTIONS * 10];
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class A64HookInit
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
A64HookInit()
|
||||||
|
{
|
||||||
|
__make_rwx(__insns_pool, sizeof(__insns_pool));
|
||||||
|
A64_LOGI("insns pool initialized.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
static A64HookInit __init;
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static uint32_t *FastAllocateTrampoline()
|
||||||
|
{
|
||||||
|
static_assert((A64_MAX_INSTRUCTIONS * 10 * sizeof(uint32_t)) % 8 == 0, "8-byte align");
|
||||||
|
static volatile int32_t __index = -1;
|
||||||
|
|
||||||
|
int32_t i = __atomic_increase(&__index);
|
||||||
|
if (__predict_true(i >= 0 && i < __countof(__insns_pool))) {
|
||||||
|
return __insns_pool[i];
|
||||||
|
} //if
|
||||||
|
|
||||||
|
A64_LOGE("failed to allocate trampoline!");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
A64_JNIEXPORT void *A64HookFunctionV(void *const symbol, void *const replace,
|
||||||
|
void *const rwx, const uintptr_t rwx_size)
|
||||||
|
{
|
||||||
|
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||||
|
|
||||||
|
uint32_t *trampoline = static_cast<uint32_t *>(rwx), *original = static_cast<uint32_t *>(symbol);
|
||||||
|
|
||||||
|
static_assert(A64_MAX_INSTRUCTIONS >= 5, "please fix A64_MAX_INSTRUCTIONS!");
|
||||||
|
auto pc_offset = static_cast<int64_t>(__intval(replace) - __intval(symbol)) >> 2;
|
||||||
|
if (llabs(pc_offset) >= (mask >>1)) {
|
||||||
|
int32_t count = (reinterpret_cast<uint64_t>(original + 2) & 7u) != 0u ? 5 : 4;
|
||||||
|
if (trampoline) {
|
||||||
|
if (rwx_size < count * 10u) {
|
||||||
|
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", count * 10u);
|
||||||
|
return NULL;
|
||||||
|
} //if
|
||||||
|
__fix_instructions(original, count, trampoline);
|
||||||
|
} //if
|
||||||
|
|
||||||
|
if (__make_rwx(original, 5 * sizeof(uint32_t)) == 0) {
|
||||||
|
if (count == 5) {
|
||||||
|
original[0] = A64_NOP;
|
||||||
|
++original;
|
||||||
|
} //if
|
||||||
|
original[0] = 0x58000051u; // LDR X17, #0x8
|
||||||
|
original[1] = 0xd61f0220u; // BR X17
|
||||||
|
*reinterpret_cast<int64_t *>(original + 2) = __intval(replace);
|
||||||
|
__flush_cache(symbol, 5 * sizeof(uint32_t));
|
||||||
|
|
||||||
|
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
|
||||||
|
symbol, replace, 5 * sizeof(uint32_t));
|
||||||
|
} else {
|
||||||
|
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
|
||||||
|
errno, original, 5 * sizeof(uint32_t));
|
||||||
|
trampoline = NULL;
|
||||||
|
} //if
|
||||||
|
} else {
|
||||||
|
if (trampoline) {
|
||||||
|
if (rwx_size < 1u * 10u) {
|
||||||
|
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", 1u * 10u);
|
||||||
|
return NULL;
|
||||||
|
} //if
|
||||||
|
__fix_instructions(original, 1, trampoline);
|
||||||
|
} //if
|
||||||
|
|
||||||
|
if (__make_rwx(original, 1 * sizeof(uint32_t)) == 0) {
|
||||||
|
__sync_cmpswap(original, *original, 0x14000000u | (pc_offset & mask)); // "B" ADDR_PCREL26
|
||||||
|
__flush_cache(symbol, 1 * sizeof(uint32_t));
|
||||||
|
|
||||||
|
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
|
||||||
|
symbol, replace, 1 * sizeof(uint32_t));
|
||||||
|
} else {
|
||||||
|
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
|
||||||
|
errno, original, 1 * sizeof(uint32_t));
|
||||||
|
trampoline = NULL;
|
||||||
|
} //if
|
||||||
|
} //if
|
||||||
|
|
||||||
|
return trampoline;
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------------
|
||||||
|
|
||||||
|
A64_JNIEXPORT void A64HookFunction(void *const symbol, void *const replace, void **result)
|
||||||
|
{
|
||||||
|
void *trampoline = NULL;
|
||||||
|
if (result != NULL) {
|
||||||
|
trampoline = FastAllocateTrampoline();
|
||||||
|
*result = trampoline;
|
||||||
|
if (trampoline == NULL) return;
|
||||||
|
} //if
|
||||||
|
|
||||||
|
// fix Android 10 .text segment is read-only by default
|
||||||
|
__make_rwx(symbol, 5 * sizeof(size_t));
|
||||||
|
|
||||||
|
trampoline = A64HookFunctionV(symbol, replace, trampoline, A64_MAX_INSTRUCTIONS * 10u);
|
||||||
|
if (trampoline == NULL && result != NULL) {
|
||||||
|
*result = NULL;
|
||||||
|
} //if
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // defined(__aarch64__)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* @date : 2018/04/18
|
||||||
|
* @author : Rprop (r_prop@outlook.com)
|
||||||
|
* https://github.com/Rprop/And64InlineHook
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2018 Rprop (r_prop@outlook.com)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
#define A64_MAX_BACKUPS 256
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
void A64HookFunction(void *const symbol, void *const replace, void **result);
|
||||||
|
void *A64HookFunctionV(void *const symbol, void *const replace,
|
||||||
|
void *const rwx, const uintptr_t rwx_size);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2017 RLib
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"versionName": "20230317",
|
||||||
|
"versionCode": "2128210",
|
||||||
|
"il2cppSha256": "842a496cc59948af03fe435e0abe0a173cbf2a6bf439be48096ad2b2c2dccbb9",
|
||||||
|
"hookBytes": 20,
|
||||||
|
"targets": {
|
||||||
|
"http_send_request": {
|
||||||
|
"rva": 108025384,
|
||||||
|
"length": 456,
|
||||||
|
"fingerprint": "0x8cd03afe41eecdef",
|
||||||
|
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
|
||||||
|
},
|
||||||
|
"http_call_callback": {
|
||||||
|
"rva": 108061608,
|
||||||
|
"length": 424,
|
||||||
|
"fingerprint": "0x98ed61ac83527269",
|
||||||
|
"why": "Logs each finished request's status (and the body of non-2xx responses)."
|
||||||
|
},
|
||||||
|
"oculus_get_app_id": {
|
||||||
|
"rva": 166405008,
|
||||||
|
"length": 32,
|
||||||
|
"fingerprint": "0xaa5a50a8d4246c46",
|
||||||
|
"why": "Returns the operator's Meta app id when OculusAppId is set."
|
||||||
|
},
|
||||||
|
"oculus_get_mobile_app_id": {
|
||||||
|
"rva": 166380836,
|
||||||
|
"length": 32,
|
||||||
|
"fingerprint": "0x5f5d3bc64dedb8de",
|
||||||
|
"why": "Same as get_AppID; the Quest build reads this getter."
|
||||||
|
},
|
||||||
|
"photon_app_settings": {
|
||||||
|
"rva": 69268008,
|
||||||
|
"length": 196,
|
||||||
|
"fingerprint": "0xc8050d166579e43b",
|
||||||
|
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
|
||||||
|
},
|
||||||
|
"tls_unity_verify": {
|
||||||
|
"rva": 159789160,
|
||||||
|
"length": 884,
|
||||||
|
"fingerprint": "0xf8d4a696f15e6f0e",
|
||||||
|
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
|
||||||
|
},
|
||||||
|
"tls_bc_notify_server_certificate": {
|
||||||
|
"rva": 103301824,
|
||||||
|
"length": 284,
|
||||||
|
"fingerprint": "0x7f061bfc843e881f",
|
||||||
|
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
|
||||||
|
},
|
||||||
|
"pin_nameserver": {
|
||||||
|
"rva": 96989180,
|
||||||
|
"length": 332,
|
||||||
|
"fingerprint": "0x659c928fe96e1008",
|
||||||
|
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
|
||||||
|
},
|
||||||
|
"pin_https": {
|
||||||
|
"rva": 103249928,
|
||||||
|
"length": 1032,
|
||||||
|
"fingerprint": "0x943a238f04d54362",
|
||||||
|
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
|
||||||
|
},
|
||||||
|
"pin_bc_verifier": {
|
||||||
|
"rva": 82659376,
|
||||||
|
"length": 1128,
|
||||||
|
"fingerprint": "0x60415d1d29a7315d",
|
||||||
|
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
|
||||||
|
},
|
||||||
|
"image_signature_verify": {
|
||||||
|
"rva": 97302848,
|
||||||
|
"length": 732,
|
||||||
|
"fingerprint": "0xe1549ef6f4691a33",
|
||||||
|
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
|
||||||
|
},
|
||||||
|
"hile_warning_callback": {
|
||||||
|
"rva": 75750004,
|
||||||
|
"length": 352,
|
||||||
|
"fingerprint": "0xccf510feeada40d9",
|
||||||
|
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
|
||||||
|
},
|
||||||
|
"unknown_dll_callback": {
|
||||||
|
"rva": 57998052,
|
||||||
|
"length": 112,
|
||||||
|
"fingerprint": "0x84e1fed3a70e7560",
|
||||||
|
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
|
||||||
|
},
|
||||||
|
"file_hash_callback": {
|
||||||
|
"rva": 52872472,
|
||||||
|
"length": 1052,
|
||||||
|
"fingerprint": "0x03a8b6f1b373e600",
|
||||||
|
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
|
||||||
|
},
|
||||||
|
"toxmod_can_initialize": {
|
||||||
|
"rva": 55928080,
|
||||||
|
"length": 1040,
|
||||||
|
"fingerprint": "0xf72dd6f5dbb16216",
|
||||||
|
"why": "Third-party voice moderation service; forced false, as on PC."
|
||||||
|
},
|
||||||
|
"cheat_report": {
|
||||||
|
"rva": 106127656,
|
||||||
|
"length": 2248,
|
||||||
|
"fingerprint": "0x9c1e0ef53e5a3ec8",
|
||||||
|
"why": "Log-only: names any integrity report so a new one is visible in logcat."
|
||||||
|
},
|
||||||
|
"fatal_application_quit": {
|
||||||
|
"rva": 55748824,
|
||||||
|
"length": 348,
|
||||||
|
"fingerprint": "0x8cd03afe41eecdef",
|
||||||
|
"why": "Log-only: prints the quit reason and caller."
|
||||||
|
},
|
||||||
|
"try_application_quit": {
|
||||||
|
"rva": 55748412,
|
||||||
|
"length": 412,
|
||||||
|
"fingerprint": "0xb12b0044ac4f844f",
|
||||||
|
"why": "Log-only: prints the quit code and caller."
|
||||||
|
},
|
||||||
|
"title_auto_login": {
|
||||||
|
"rva": 58892048,
|
||||||
|
"length": 668,
|
||||||
|
"fingerprint": "0x83bebb9a30983b33",
|
||||||
|
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
{
|
||||||
|
"_comment": [
|
||||||
|
"Hook targets, identified by the class header and method signature exactly as",
|
||||||
|
"Il2CppDumper prints them in dump.cs. scripts/resolve_targets.py turns this into a",
|
||||||
|
"per-build table (RVA, length, code fingerprint). Obfuscated names are fixed per",
|
||||||
|
"release and shared with the PC build of the same release; when rebasing, re-check",
|
||||||
|
"each one against the PC decompile and update the signature here."
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"id": "http_send_request",
|
||||||
|
"class": "public static class HTTPManager ",
|
||||||
|
"method": "public static HTTPRequest SendRequest(HTTPRequest request)",
|
||||||
|
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "http_call_callback",
|
||||||
|
"class": "public sealed class HTTPRequest ",
|
||||||
|
"method": "internal void CallCallback()",
|
||||||
|
"why": "Logs each finished request's status (and the body of non-2xx responses)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "oculus_get_app_id",
|
||||||
|
"class": "public sealed class PlatformSettings ",
|
||||||
|
"method": "public static string get_AppID()",
|
||||||
|
"why": "Returns the operator's Meta app id when OculusAppId is set."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "oculus_get_mobile_app_id",
|
||||||
|
"class": "public sealed class PlatformSettings ",
|
||||||
|
"method": "public static string get_MobileAppID()",
|
||||||
|
"why": "Same as get_AppID; the Quest build reads this getter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "photon_app_settings",
|
||||||
|
"class": "internal class NFOMFIAOKFO ",
|
||||||
|
"method": "public AppSettings LBKJOHGFHOO()",
|
||||||
|
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tls_unity_verify",
|
||||||
|
"class": "internal class UnityTlsContext ",
|
||||||
|
"method": "private UnityTls.unitytls_x509verify_result VerifyCallback(UnityTls.unitytls_x509list_ref chain, UnityTls.unitytls_errorstate* errorState)",
|
||||||
|
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tls_bc_notify_server_certificate",
|
||||||
|
"class": "public class LegacyTlsAuthentication ",
|
||||||
|
"method": "public virtual void NotifyServerCertificate(Certificate serverCertificate)",
|
||||||
|
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pin_nameserver",
|
||||||
|
"class": "public class PAPLNIPKAMG ",
|
||||||
|
"method": "private static bool GKHFNFDOKDK(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
|
||||||
|
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pin_https",
|
||||||
|
"class": "public static class LJEHIHEHMCN ",
|
||||||
|
"method": "public static bool JFIODJJKMBF(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
|
||||||
|
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pin_bc_verifier",
|
||||||
|
"class": "public class LJEHIHEHMCN.NALNEIDKBOC ",
|
||||||
|
"method": "public bool IsValid(Uri HEEAJLIFCHN, X509CertificateStructure[] MFLGMAEAFON)",
|
||||||
|
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "image_signature_verify",
|
||||||
|
"class": "public class KLJOGJHBONK ",
|
||||||
|
"method": "private static bool PBLLFOKJAMK(byte[] HBMMCJPOJGP, byte[] JJGOGMKDLGI)",
|
||||||
|
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hile_warning_callback",
|
||||||
|
"class": "private sealed class FPIBGPIAOBI.KHIIHOIFDMP ",
|
||||||
|
"method": "internal void <CreateHileWarning>b__0(bool shouldQuit)",
|
||||||
|
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unknown_dll_callback",
|
||||||
|
"class": "private sealed class CheatManager.NOBCKPOGHEB ",
|
||||||
|
"method": "internal void <OnUnknownDLLDetected>b__0()",
|
||||||
|
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "file_hash_callback",
|
||||||
|
"class": "private sealed class BEPEAHOOLDB.PBENKLNHBHL ",
|
||||||
|
"method": "internal void <InitializeFileHashChecker>b__0(JFDPIJFDHAD.ACCABIOJGNE result)",
|
||||||
|
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "toxmod_can_initialize",
|
||||||
|
"class": "public class ToxModVoiceComponent ",
|
||||||
|
"method": "public static bool CanInitializeToxMod()",
|
||||||
|
"why": "Third-party voice moderation service; forced false, as on PC."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cheat_report",
|
||||||
|
"class": "public class FPIBGPIAOBI ",
|
||||||
|
"method": "public static LDGADANDBIO IEIHCFLCHBE(FPIBGPIAOBI.HHKINDNBLEE EHPFPOFFCEJ, string EPAIMCEIMPA, Nullable<int> BNCEPENKLDF)",
|
||||||
|
"why": "Log-only: names any integrity report so a new one is visible in logcat."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fatal_application_quit",
|
||||||
|
"class": "public class SessionManager ",
|
||||||
|
"method": "public static void FatalApplicationQuit(int DKOGJICFAEP, string EPAIMCEIMPA = \"\")",
|
||||||
|
"why": "Log-only: prints the quit reason and caller."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "try_application_quit",
|
||||||
|
"class": "public class SessionManager ",
|
||||||
|
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
|
||||||
|
"why": "Log-only: prints the quit code and caller."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "title_auto_login",
|
||||||
|
"class": "public class TitleScreenFlowModel ",
|
||||||
|
"method": "private LDGADANDBIO IAKHIJIHAPJ(LBNJFPOLCDL.MDGJJCFGJNF AJCKAHHJDEN)",
|
||||||
|
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Hubs;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stand-in for the notify hub that records every push instead of sending it,
|
||||||
|
/// so tests can assert on the exact frames the game client would receive.
|
||||||
|
/// A frame is the hub method plus its single string argument, parsed back into
|
||||||
|
/// the <c>{Id, Msg}</c> envelope.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CapturingHubContext : IHubContext<NotifyHub>
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<SentFrame> _sent = new();
|
||||||
|
|
||||||
|
public IHubClients Clients => new CapturingClients(this);
|
||||||
|
public IGroupManager Groups { get; } = new NoopGroups();
|
||||||
|
|
||||||
|
public IReadOnlyList<SentFrame> Sent => _sent.ToArray();
|
||||||
|
|
||||||
|
/// <summary>Frames sent to a target such as <c>group:player:12</c>, in
|
||||||
|
/// order, optionally filtered by envelope <c>Id</c>.</summary>
|
||||||
|
public IReadOnlyList<SentFrame> To(string target, string? id = null) =>
|
||||||
|
_sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList();
|
||||||
|
|
||||||
|
public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}";
|
||||||
|
|
||||||
|
internal void Record(string target, IReadOnlyList<string>? excluded, string method, object?[] args) =>
|
||||||
|
_sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty<string>(), method,
|
||||||
|
args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args)));
|
||||||
|
|
||||||
|
public sealed record SentFrame(string Target, IReadOnlyList<string> Excluded, string Method, string Payload)
|
||||||
|
{
|
||||||
|
public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement;
|
||||||
|
public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null;
|
||||||
|
public JsonElement Msg => Envelope.GetProperty("Msg");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingClients(CapturingHubContext owner) : IHubClients
|
||||||
|
{
|
||||||
|
private IClientProxy Proxy(string target, IReadOnlyList<string>? excluded = null) =>
|
||||||
|
new CapturingProxy(owner, target, excluded);
|
||||||
|
|
||||||
|
public IClientProxy All => Proxy("all");
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("all", excludedConnectionIds);
|
||||||
|
public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}");
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}");
|
||||||
|
public IClientProxy Group(string groupName) => Proxy($"group:{groupName}");
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
|
||||||
|
Proxy($"group:{groupName}", excludedConnectionIds);
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList());
|
||||||
|
public IClientProxy User(string userId) => Proxy($"user:{userId}");
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy($"users:{string.Join(",", userIds)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList<string>? excluded) : IClientProxy
|
||||||
|
{
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
owner.Record(target, excluded, method, args);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FanOutProxy(IReadOnlyList<IClientProxy> proxies) : IClientProxy
|
||||||
|
{
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopGroups : IGroupManager
|
||||||
|
{
|
||||||
|
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
using DorkNet.Server.Services;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The March-2023 client budgets every CircuitsV2 network send against
|
||||||
|
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>, which it fills from the
|
||||||
|
/// <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. Retail ships 5 RPCs per
|
||||||
|
/// frame; past that the send is dropped and the room save is flagged
|
||||||
|
/// <c>cv2_rpc_limited</c>. DorkNet serves budgets that lift the cap, and
|
||||||
|
/// these tests pin both the wire shape the client's Newtonsoft reader
|
||||||
|
/// binds and the clamps that keep a hand-edited value from crashing it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CircuitsV2LimitsTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public CircuitsV2LimitsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Gameconfigs_endpoint_serves_the_network_budget()
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
|
||||||
|
var rows = await client.GetFromJsonAsync<List<GameConfigurationSetting>>(
|
||||||
|
"api/gameconfigs/v1/all");
|
||||||
|
|
||||||
|
var network = rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Network");
|
||||||
|
var entry = JsonDocument.Parse(network.Value).RootElement.EnumerateArray().Single();
|
||||||
|
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||||
|
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||||
|
// A server that never saved limits (empty column) serves the
|
||||||
|
// current defaults, not the retail values.
|
||||||
|
Assert.Equal("30000", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Chips").Value);
|
||||||
|
Assert.Equal("0", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.InkPerChip").Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<GameConfigurationSetting> Emit(CircuitsV2LimitSettings limits) =>
|
||||||
|
ServerSettingsService.ToGameConfigurations(
|
||||||
|
ServerSettingsService.DefaultRecCenterDoors(),
|
||||||
|
ServerSettingsService.DefaultDiscoveredGameConfigs(),
|
||||||
|
limits);
|
||||||
|
|
||||||
|
private static string ValueOf(IReadOnlyList<GameConfigurationSetting> rows, string key) =>
|
||||||
|
rows.Single(r => r.Key == key).Value;
|
||||||
|
|
||||||
|
private static JsonElement NetworkOf(CircuitsV2LimitSettings limits) =>
|
||||||
|
JsonDocument.Parse(ValueOf(Emit(limits), "UGC.CircuitsV2.Limit.Network")).RootElement;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_lift_the_rpc_and_byte_budgets()
|
||||||
|
{
|
||||||
|
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits());
|
||||||
|
|
||||||
|
Assert.Equal(JsonValueKind.Array, network.ValueKind);
|
||||||
|
var entry = Assert.Single(network.EnumerateArray());
|
||||||
|
// Retail is 5 RPCs / 1024 bytes; anything at those values means the
|
||||||
|
// budget never actually got lifted.
|
||||||
|
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||||
|
Assert.True(entry.GetProperty("Bytes").GetInt32() >= 1_000_000);
|
||||||
|
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Network_json_uses_the_field_names_the_client_binds()
|
||||||
|
{
|
||||||
|
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits())
|
||||||
|
.EnumerateArray().First();
|
||||||
|
|
||||||
|
// LimitConfig.NetworkConfig's il2cpp field names, verbatim.
|
||||||
|
Assert.True(entry.TryGetProperty("NumPlayersForActivation", out _));
|
||||||
|
Assert.True(entry.TryGetProperty("Bytes", out _));
|
||||||
|
Assert.True(entry.TryGetProperty("BytesTimeWindowFrame60s", out _));
|
||||||
|
Assert.True(entry.TryGetProperty("Rpcs", out _));
|
||||||
|
Assert.True(entry.TryGetProperty("RpcTimeWindowFrame60s", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Scalar_limits_serialize_invariant_without_separators()
|
||||||
|
{
|
||||||
|
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||||
|
{
|
||||||
|
Chips = 123456,
|
||||||
|
CloudValuesAutosaveSeconds = 300f,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The client parses these with int/float.TryParse under
|
||||||
|
// InvariantCulture, so a grouped or comma-decimal value silently
|
||||||
|
// falls back to its baked default.
|
||||||
|
Assert.Equal("123456", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
|
||||||
|
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
|
||||||
|
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||||
|
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_raise_the_room_size_limits_past_retail()
|
||||||
|
{
|
||||||
|
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits());
|
||||||
|
|
||||||
|
// Retail: Compute 50000, Chips 3000, CloudValues 100,
|
||||||
|
// Unculled/VisibleChips 500, InkPerChip 15.
|
||||||
|
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||||
|
Assert.Equal("30000", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
|
||||||
|
Assert.Equal("1000", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
|
||||||
|
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.UnculledChips"));
|
||||||
|
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.VisibleChips"));
|
||||||
|
// InkPerChip is a cost, so the lift is downward.
|
||||||
|
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||||
|
// A save cadence, not a capacity limit: left at retail.
|
||||||
|
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_visible_chips_do_not_exceed_the_chip_cap()
|
||||||
|
{
|
||||||
|
var limits = ServerSettingsService.DefaultCircuitsV2Limits();
|
||||||
|
|
||||||
|
// The add-chip path only charges InkPerChip when Chips < VisibleChips
|
||||||
|
// (CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt ISIL 228-259), so the
|
||||||
|
// defaults keep that path off as well as zeroing the cost.
|
||||||
|
Assert.True(limits.VisibleChips <= limits.Chips);
|
||||||
|
Assert.True(limits.UnculledChips <= limits.Chips);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ink_cost_and_cloud_values_are_capped_so_their_product_fits_an_int()
|
||||||
|
{
|
||||||
|
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||||
|
{
|
||||||
|
InkPerChip = int.MaxValue,
|
||||||
|
CloudValues = int.MaxValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The client computes InkPerChip * Math.Min(numChips, CloudValues)
|
||||||
|
// in plain int; InkPerChip past the hard-coded 30000-ink room cap
|
||||||
|
// also makes a single chip unaffordable.
|
||||||
|
var ink = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||||
|
var cloud = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
|
||||||
|
Assert.Equal(30_000, ink);
|
||||||
|
Assert.Equal(10_000, cloud);
|
||||||
|
Assert.True((long)ink * cloud < int.MaxValue / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Negative_ink_cost_is_floored_at_zero()
|
||||||
|
{
|
||||||
|
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { InkPerChip = -5 });
|
||||||
|
|
||||||
|
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Chips_are_bounded_because_the_culling_manager_preallocates_by_them()
|
||||||
|
{
|
||||||
|
var low = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = 0 });
|
||||||
|
var high = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = int.MaxValue });
|
||||||
|
|
||||||
|
// Four List<Chip>(Chips) at culling-manager construction: 0 or less
|
||||||
|
// throws, and int.MaxValue is an out-of-memory kill.
|
||||||
|
Assert.Equal("1", ValueOf(low, "UGC.CircuitsV2.Limit.Chips"));
|
||||||
|
Assert.Equal("1000000", ValueOf(high, "UGC.CircuitsV2.Limit.Chips"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_stays_finite_because_it_is_the_runaway_loop_guard()
|
||||||
|
{
|
||||||
|
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Compute = int.MaxValue });
|
||||||
|
|
||||||
|
Assert.Equal("10000000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Zero_rpcs_is_clamped_because_the_debug_subscreen_divides_by_it()
|
||||||
|
{
|
||||||
|
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||||
|
{
|
||||||
|
Network = [new(0, 1024, 60, Rpcs: 0, RpcTimeWindowFrame60s: 1)],
|
||||||
|
}).EnumerateArray().First();
|
||||||
|
|
||||||
|
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Time_windows_are_capped_so_the_ring_buffer_stays_small()
|
||||||
|
{
|
||||||
|
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||||
|
{
|
||||||
|
Network = [new(0, 1024, int.MaxValue, 5, int.MaxValue)],
|
||||||
|
}).EnumerateArray().First();
|
||||||
|
|
||||||
|
// Each window frame is one int in a client-side ring buffer, so an
|
||||||
|
// unbounded window is an out-of-memory kill at circuit init.
|
||||||
|
Assert.Equal(3600, entry.GetProperty("RpcTimeWindowFrame60s").GetInt32());
|
||||||
|
Assert.Equal(3600, entry.GetProperty("BytesTimeWindowFrame60s").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Lowest_entry_is_forced_to_activate_at_zero_players()
|
||||||
|
{
|
||||||
|
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||||
|
{
|
||||||
|
Network =
|
||||||
|
[
|
||||||
|
new(20, 4096, 60, 50, 1),
|
||||||
|
new(8, 2048, 60, 25, 1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without an entry at 0 the client rejects the whole array and
|
||||||
|
// falls back to the retail 5-RPC budget.
|
||||||
|
var entries = network.EnumerateArray().ToList();
|
||||||
|
Assert.Equal(0, entries[0].GetProperty("NumPlayersForActivation").GetInt32());
|
||||||
|
// The rewrite keeps that entry's own budget.
|
||||||
|
Assert.Equal(25, entries[0].GetProperty("Rpcs").GetInt32());
|
||||||
|
Assert.Equal(20, entries[1].GetProperty("NumPlayersForActivation").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Duplicate_thresholds_collapse_and_entries_sort_ascending()
|
||||||
|
{
|
||||||
|
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||||
|
{
|
||||||
|
Network =
|
||||||
|
[
|
||||||
|
new(16, 4096, 60, 50, 1),
|
||||||
|
new(0, 1024, 60, 10, 1),
|
||||||
|
new(16, 8192, 60, 99, 1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
var thresholds = network.EnumerateArray()
|
||||||
|
.Select(e => e.GetProperty("NumPlayersForActivation").GetInt32())
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal([0, 16], thresholds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_network_list_falls_back_to_the_default_budget()
|
||||||
|
{
|
||||||
|
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||||
|
{
|
||||||
|
Network = [],
|
||||||
|
});
|
||||||
|
|
||||||
|
var entry = Assert.Single(network.EnumerateArray());
|
||||||
|
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||||
|
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using DorkNet.Server.Controllers.Site;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>The site's downloads proxy: off without config, only on the site's
|
||||||
|
/// own host, serves only files the manifest lists, and passes Range through so
|
||||||
|
/// the browser Quest installer can read an .xapk in place.</summary>
|
||||||
|
public sealed class DownloadsControllerTests
|
||||||
|
{
|
||||||
|
private const string Apex = "dorknet.example.com";
|
||||||
|
|
||||||
|
private const string Manifest = """
|
||||||
|
{"releases":[{"id":"2023.03","title":"March 2023","files":[
|
||||||
|
{"id":"quest","platform":"quest","name":"DorkNet-2023.03-Quest.xapk","path":"2023.03/DorkNet-2023.03-Quest.xapk","size":10,"sha256":"x"}]}]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Without_a_base_url_the_manifest_is_404()
|
||||||
|
{
|
||||||
|
var (controller, _) = Create(baseUrl: null, _ => throw new InvalidOperationException("no request expected"));
|
||||||
|
|
||||||
|
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("api.dorknet.example.com")]
|
||||||
|
[InlineData("cdn.dorknet.example.com")]
|
||||||
|
public async Task Other_hosts_get_404_without_touching_the_bucket(string host)
|
||||||
|
{
|
||||||
|
var (controller, ctx) = Create("http://bucket.internal:3902", _ => throw new InvalidOperationException("no request expected"));
|
||||||
|
ctx.Request.Host = new HostString(host);
|
||||||
|
|
||||||
|
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
|
||||||
|
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
|
||||||
|
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_listed_paths_are_found()
|
||||||
|
{
|
||||||
|
var manifest = (JsonObject)JsonNode.Parse(Manifest)!;
|
||||||
|
|
||||||
|
Assert.NotNull(DownloadsService.FindFile(manifest, "2023.03/DorkNet-2023.03-Quest.xapk"));
|
||||||
|
Assert.Null(DownloadsService.FindFile(manifest, "manifest.json"));
|
||||||
|
Assert.Null(DownloadsService.FindFile(manifest, "2023.03/../manifest.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unlisted_file_is_404_and_never_fetched()
|
||||||
|
{
|
||||||
|
var requested = new List<string>();
|
||||||
|
var (controller, ctx) = Create("http://bucket.internal:3902", req =>
|
||||||
|
{
|
||||||
|
requested.Add(req.RequestUri!.AbsolutePath);
|
||||||
|
return Json(Manifest);
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.GetFile("index.html", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
|
||||||
|
Assert.Equal(["/manifest.json"], requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Range_is_forwarded_and_the_partial_response_streamed()
|
||||||
|
{
|
||||||
|
HttpRequestMessage? fileRequest = null;
|
||||||
|
var (controller, ctx) = Create("http://bucket.internal:3902/", req =>
|
||||||
|
{
|
||||||
|
if (req.RequestUri!.AbsolutePath == "/manifest.json") return Json(Manifest);
|
||||||
|
fileRequest = req;
|
||||||
|
var res = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent("abcd"u8.ToArray()) };
|
||||||
|
res.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 10);
|
||||||
|
return res;
|
||||||
|
}, host: "downloads.example.com");
|
||||||
|
ctx.Request.Headers.Range = "bytes=2-5";
|
||||||
|
|
||||||
|
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(fileRequest);
|
||||||
|
Assert.Equal("/2023.03/DorkNet-2023.03-Quest.xapk", fileRequest!.RequestUri!.AbsolutePath);
|
||||||
|
Assert.Equal("bytes=2-5", fileRequest.Headers.Range!.ToString());
|
||||||
|
Assert.Equal("downloads.example.com", fileRequest.Headers.Host);
|
||||||
|
Assert.Equal(StatusCodes.Status206PartialContent, ctx.Response.StatusCode);
|
||||||
|
Assert.Equal("bytes 2-5/10", ctx.Response.Headers.ContentRange.ToString());
|
||||||
|
Assert.Equal("bytes", ctx.Response.Headers.AcceptRanges.ToString());
|
||||||
|
Assert.Equal("abcd", Encoding.ASCII.GetString(((MemoryStream)ctx.Response.Body).ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DownloadsController, HttpContext) Create(
|
||||||
|
string? baseUrl, Func<HttpRequestMessage, HttpResponseMessage> handler, string? host = null)
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["DorkNet:Downloads:BaseUrl"] = baseUrl,
|
||||||
|
["DorkNet:Downloads:Host"] = host,
|
||||||
|
}).Build();
|
||||||
|
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
|
||||||
|
var ctx = new DefaultHttpContext();
|
||||||
|
ctx.Request.Host = new HostString(Apex);
|
||||||
|
ctx.Response.Body = new MemoryStream();
|
||||||
|
var controller = new DownloadsController(service, new DomainConfig(Apex))
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = ctx },
|
||||||
|
};
|
||||||
|
return (controller, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpResponseMessage Json(string body) =>
|
||||||
|
new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||||
|
|
||||||
|
private sealed class StubFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory
|
||||||
|
{
|
||||||
|
public HttpClient CreateClient(string name) => new(new StubHandler(handler));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||||
|
=> Task.FromResult(handler(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Website / admin-panel sign-in with an external OpenID Connect provider.
|
||||||
|
/// The provider round-trip can't run in-process, so each test starts where the
|
||||||
|
/// provider callback leaves off: a <c>DorkNet.External</c> cookie holding the
|
||||||
|
/// provider identity, encrypted with the app's own cookie format. Everything
|
||||||
|
/// from there (link, session, admin SSO, unlink, limits) is the real code.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExternalSignInTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private const string Issuer = "https://idp.example.com";
|
||||||
|
// Test player ids: sequential, in a range no other test uses.
|
||||||
|
private static long _nextPlayerId = 9_600_000;
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public ExternalSignInTests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Linking_with_the_account_password_signs_in_and_the_next_sign_in_needs_no_password()
|
||||||
|
{
|
||||||
|
var (player, password) = await PlayerAsync();
|
||||||
|
var subject = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
using var site = Client();
|
||||||
|
var link = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||||
|
new { username = player.Username, password }, ExternalCookie(subject, "player@example.com"));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
|
||||||
|
var session = SessionCookie(link);
|
||||||
|
Assert.NotNull(session);
|
||||||
|
|
||||||
|
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||||
|
Assert.True(me.GetProperty("signedIn").GetBoolean());
|
||||||
|
Assert.Equal(player.Username, me.GetProperty("player").GetProperty("username").GetString());
|
||||||
|
Assert.Equal("player@example.com", me.GetProperty("player").GetProperty("email").GetString());
|
||||||
|
|
||||||
|
// Next time the provider identity alone is enough.
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var signIn = scope.ServiceProvider.GetRequiredService<ExternalSignInService>();
|
||||||
|
var stored = await signIn.FindAsync(Issuer, subject);
|
||||||
|
Assert.Equal(player.Id, stored?.PlayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wrong_passwords_are_refused_then_rate_limited()
|
||||||
|
{
|
||||||
|
var (player, _) = await PlayerAsync();
|
||||||
|
var external = ExternalCookie(Guid.NewGuid().ToString("N"), "guess@example.com");
|
||||||
|
using var site = Client();
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
using var r = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||||
|
new { username = player.Username, password = "wrong-" + i }, external);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode);
|
||||||
|
}
|
||||||
|
using var blocked = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||||
|
new { username = player.Username, password = "still-wrong" }, external);
|
||||||
|
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_identity_linked_to_one_account_cannot_be_linked_to_another()
|
||||||
|
{
|
||||||
|
var (first, firstPassword) = await PlayerAsync();
|
||||||
|
var (second, secondPassword) = await PlayerAsync();
|
||||||
|
var subject = Guid.NewGuid().ToString("N");
|
||||||
|
using var site = Client();
|
||||||
|
|
||||||
|
using var ok = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||||
|
new { username = first.Username, password = firstPassword }, ExternalCookie(subject, "a@example.com"));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||||
|
using var refused = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||||
|
new { username = second.Username, password = secondPassword }, ExternalCookie(subject, "a@example.com"));
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_sso_issues_tokens_only_to_admins()
|
||||||
|
{
|
||||||
|
var (admin, adminPassword) = await PlayerAsync(isAdmin: true);
|
||||||
|
var (regular, regularPassword) = await PlayerAsync();
|
||||||
|
using var admins = Client("admin");
|
||||||
|
|
||||||
|
var adminSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
|
||||||
|
new { username = admin.Username, password = adminPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||||
|
using var tokens = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", adminSession);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, tokens.StatusCode);
|
||||||
|
var body = JsonDocument.Parse(await tokens.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.False(string.IsNullOrEmpty(body.GetProperty("access_token").GetString()));
|
||||||
|
Assert.Equal(admin.Id, body.GetProperty("account_id").GetInt64());
|
||||||
|
|
||||||
|
var regularSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
|
||||||
|
new { username = regular.Username, password = regularPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||||
|
using var refused = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", regularSession);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
|
||||||
|
|
||||||
|
using var anonymous = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", null);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unlinking_ends_the_session()
|
||||||
|
{
|
||||||
|
var (player, password) = await PlayerAsync();
|
||||||
|
using var site = Client();
|
||||||
|
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||||
|
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||||
|
|
||||||
|
using var unlink = await SendAsync(site, HttpMethod.Post, "/api/site/v1/account/unlink", session);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, unlink.StatusCode);
|
||||||
|
|
||||||
|
// The old cookie is still in the browser, but the link behind it is gone.
|
||||||
|
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||||
|
Assert.False(me.GetProperty("signedIn").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_banned_player_cannot_link_and_loses_an_existing_session()
|
||||||
|
{
|
||||||
|
var (player, password) = await PlayerAsync();
|
||||||
|
using var site = Client();
|
||||||
|
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||||
|
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||||
|
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
await db.Players.Where(p => p.Id == player.Id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(p => p.BannedUntil, DateTime.UtcNow.AddDays(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||||
|
Assert.False(me.GetProperty("signedIn").GetBoolean());
|
||||||
|
using var relink = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||||
|
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, relink.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cookie_keys_are_stored_encrypted()
|
||||||
|
{
|
||||||
|
var (player, password) = await PlayerAsync();
|
||||||
|
using var site = Client();
|
||||||
|
using var link = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||||
|
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
|
||||||
|
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var stored = await db.DataProtectionKeys.Select(k => k.Xml).ToListAsync();
|
||||||
|
Assert.NotEmpty(stored);
|
||||||
|
Assert.All(stored, xml =>
|
||||||
|
{
|
||||||
|
Assert.Contains("dorknetEncryptedKey", xml);
|
||||||
|
Assert.DoesNotContain("masterKey", xml);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Key_encryption_round_trips_and_rejects_another_secret()
|
||||||
|
{
|
||||||
|
var element = new System.Xml.Linq.XElement("key", new System.Xml.Linq.XElement("masterKey", "secret-bytes"));
|
||||||
|
var encrypted = new DataProtectionKeyEncryption("first-secret").Encrypt(element).EncryptedElement;
|
||||||
|
Assert.DoesNotContain("secret-bytes", encrypted.ToString());
|
||||||
|
Assert.Equal(element.ToString(), new DataProtectionKeyEncryption("first-secret").DecryptElement(encrypted).ToString());
|
||||||
|
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(
|
||||||
|
() => new DataProtectionKeyEncryption("other-secret").DecryptElement(encrypted));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Downloads_can_require_sign_in()
|
||||||
|
{
|
||||||
|
await using var factory = _factory.WithWebHostBuilder(b =>
|
||||||
|
{
|
||||||
|
b.UseSetting("DorkNet:Downloads:BaseUrl", "http://127.0.0.1:9");
|
||||||
|
b.UseSetting("DorkNet:Downloads:RequireSignIn", "true");
|
||||||
|
});
|
||||||
|
using var site = factory.CreateClient(new() { BaseAddress = new Uri($"http://{_factory.ApexDomain}") });
|
||||||
|
using var manifest = await site.GetAsync("/api/site/v1/downloads");
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, manifest.StatusCode);
|
||||||
|
using var file = await site.GetAsync("/api/site/v1/downloads/files/2023.03/DorkNet-2023.03-Quest.xapk");
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, file.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<(PlayerEntity Player, string Password)> PlayerAsync(bool isAdmin = false)
|
||||||
|
{
|
||||||
|
var password = "pw-" + Guid.NewGuid().ToString("N")[..12];
|
||||||
|
var player = new PlayerEntity
|
||||||
|
{
|
||||||
|
Id = Interlocked.Increment(ref _nextPlayerId),
|
||||||
|
Username = "sso" + Guid.NewGuid().ToString("N")[..10],
|
||||||
|
DisplayName = "SSO test",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||||
|
IsAdmin = isAdmin,
|
||||||
|
};
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
db.Players.Add(player);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return (player, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The cookie the provider callback leaves behind: the provider
|
||||||
|
/// identity in the DorkNet.External scheme, in the app's cookie format.</summary>
|
||||||
|
private string ExternalCookie(string subject, string email)
|
||||||
|
{
|
||||||
|
var options = _factory.Services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
|
||||||
|
.Get(ExternalSignIn.ExternalScheme);
|
||||||
|
var identity = new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim("iss", Issuer), new Claim("sub", subject), new Claim("email", email),
|
||||||
|
}, "test");
|
||||||
|
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), ExternalSignIn.ExternalScheme);
|
||||||
|
return $"{options.Cookie.Name}={options.TicketDataFormat.Protect(ticket)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? SessionCookie(HttpResponseMessage response) =>
|
||||||
|
response.Headers.TryGetValues("Set-Cookie", out var values)
|
||||||
|
? values.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("dorknet_session=") && v.Length > "dorknet_session=".Length)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private HttpClient Client(string? subdomain = null)
|
||||||
|
{
|
||||||
|
var host = subdomain is null ? _factory.ApexDomain : $"{subdomain}.{_factory.ApexDomain}";
|
||||||
|
return _factory.CreateClient(new() { BaseAddress = new Uri($"http://{host}"), HandleCookies = false });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, string? cookie)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(method, path);
|
||||||
|
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||||
|
return client.SendAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient client, string path, object body, string? cookie)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
|
||||||
|
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||||
|
return client.SendAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path, string? cookie)
|
||||||
|
{
|
||||||
|
using var response = await SendAsync(client, HttpMethod.Get, path, cookie);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||||
|
return JsonDocument.Parse(body).RootElement.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>GET api/incentivizedreferrals/referrals?take=&continuationToken=</c> is
|
||||||
|
/// assembled by the client's URL builder (RecNet.Runtime/LINKIECPDML.txt:677-737),
|
||||||
|
/// so it never showed up as a route literal and had no handler. The reply is the
|
||||||
|
/// GPJIMAHMAIO page: <c>{Referrals:[{InviteeAccountId, CreatedAt, VerifiedAt}],
|
||||||
|
/// ContinuationToken}</c> (KOEEACLMAFE.txt:203-238, ADAFNGNADFH.txt:279-346).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IncentivizedReferralsTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public IncentivizedReferralsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Referrals_page_lists_credited_invitees_with_a_continuation_token()
|
||||||
|
{
|
||||||
|
using var setup = Client();
|
||||||
|
var referrer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var inviteeA = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var inviteeB = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
// Same row LinkController.CreditReferralAsync writes on the invitee.
|
||||||
|
foreach (var invitee in new[] { inviteeA, inviteeB })
|
||||||
|
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||||
|
{
|
||||||
|
PlayerId = invitee.PlayerId,
|
||||||
|
Key = $"referral:credited:{referrer.PlayerId}:{invitee.PlayerId}",
|
||||||
|
Value = DateTime.UtcNow.ToString("O"),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = Client(referrer);
|
||||||
|
var first = JsonDocument.Parse(
|
||||||
|
await client.GetStringAsync("/api/incentivizedreferrals/referrals?take=1")).RootElement;
|
||||||
|
var firstPage = Get(first, "Referrals");
|
||||||
|
Assert.Equal(1, firstPage.GetArrayLength());
|
||||||
|
var token = Get(first, "ContinuationToken").GetString();
|
||||||
|
Assert.False(string.IsNullOrEmpty(token));
|
||||||
|
|
||||||
|
var second = JsonDocument.Parse(await client.GetStringAsync(
|
||||||
|
$"/api/incentivizedreferrals/referrals?take=1&continuationToken={token}")).RootElement;
|
||||||
|
var secondPage = Get(second, "Referrals");
|
||||||
|
Assert.Equal(1, secondPage.GetArrayLength());
|
||||||
|
Assert.Equal(JsonValueKind.Null, Get(second, "ContinuationToken").ValueKind);
|
||||||
|
|
||||||
|
var ids = new[] { firstPage[0], secondPage[0] }
|
||||||
|
.Select(e => Get(e, "InviteeAccountId").GetInt64())
|
||||||
|
.OrderBy(x => x)
|
||||||
|
.ToArray();
|
||||||
|
Assert.Equal(new[] { inviteeA.PlayerId, inviteeB.PlayerId }.OrderBy(x => x), ids);
|
||||||
|
// CreatedAt is a non-nullable DateTime on the client: it must be present.
|
||||||
|
Assert.Equal(JsonValueKind.String, Get(firstPage[0], "CreatedAt").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client formatter registers PascalCase, camelCase and lowercase.
|
||||||
|
private static JsonElement Get(JsonElement e, string pascal) =>
|
||||||
|
e.TryGetProperty(pascal, out var v) ? v : e.GetProperty(char.ToLowerInvariant(pascal[0]) + pascal[1..]);
|
||||||
|
|
||||||
|
private HttpClient Client(GameClientSession? session = null)
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||||
|
if (session is not null)
|
||||||
|
client.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Controllers.API.Inventory;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using DorkNet.Server.Hubs;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Push frames checked against the 2023-03-21 client's notification readers
|
||||||
|
/// (RecNet.Runtime). Every push is the hub method "Notification" with one
|
||||||
|
/// JSON string <c>{"Id":"<key>","Msg":{…}}</c>; the client looks the Id
|
||||||
|
/// up case-sensitively and parses Msg strictly into the handler's DTO, so a
|
||||||
|
/// wrong key or shape drops the push without any error.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NotificationContractTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public NotificationContractTests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Friend_request_pushes_each_side_its_own_relationship_and_an_inbox_message()
|
||||||
|
{
|
||||||
|
var (factory, hub) = WithCapturingHub();
|
||||||
|
using var setup = ApiClient(factory);
|
||||||
|
var sender = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
using var client = ApiClient(factory, sender);
|
||||||
|
(await client.PostAsync($"/api/relationships/v2/sendfriendrequest?id={target.PlayerId}", null))
|
||||||
|
.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// RelationshipChanged "1" = the Relationship row from each side's view.
|
||||||
|
var toTarget = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1"));
|
||||||
|
Assert.Equal("Notification", toTarget.Method);
|
||||||
|
Assert.Equal(sender.PlayerId, toTarget.Msg.GetProperty("PlayerID").GetInt64());
|
||||||
|
Assert.Equal(2, toTarget.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestReceived
|
||||||
|
Assert.False(toTarget.Msg.TryGetProperty("Reason", out _));
|
||||||
|
|
||||||
|
var toSender = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1"));
|
||||||
|
Assert.Equal(target.PlayerId, toSender.Msg.GetProperty("PlayerID").GetInt64());
|
||||||
|
Assert.Equal(1, toSender.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestSent
|
||||||
|
|
||||||
|
// MessageReceived "2" — the "X sent you a friend request" inbox row.
|
||||||
|
var invite = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "2"));
|
||||||
|
Assert.Equal(4, invite.Msg.GetProperty("Type").GetInt32()); // FriendInvite
|
||||||
|
Assert.Equal(sender.PlayerId, invite.Msg.GetProperty("FromPlayerId").GetInt64());
|
||||||
|
|
||||||
|
using var targetClient = ApiClient(factory, target);
|
||||||
|
(await targetClient.PostAsync($"/api/relationships/v2/acceptfriendrequest?id={sender.PlayerId}", null))
|
||||||
|
.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")[^1]
|
||||||
|
.Msg.GetProperty("RelationshipType").GetInt32());
|
||||||
|
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")[^1]
|
||||||
|
.Msg.GetProperty("RelationshipType").GetInt32());
|
||||||
|
var accepted = hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "2")[^1];
|
||||||
|
Assert.Equal(40, accepted.Msg.GetProperty("Type").GetInt32()); // FriendRequestAccepted
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Favorite_pushes_the_callers_updated_row_to_the_caller_only()
|
||||||
|
{
|
||||||
|
var (factory, hub) = WithCapturingHub();
|
||||||
|
using var setup = ApiClient(factory);
|
||||||
|
var me = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var other = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
using var client = ApiClient(factory, me);
|
||||||
|
(await client.GetAsync($"/api/relationships/v1/favorite?id={other.PlayerId}")).EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(me.PlayerId), "1"));
|
||||||
|
Assert.Equal(other.PlayerId, frame.Msg.GetProperty("PlayerID").GetInt64());
|
||||||
|
Assert.Equal(1, frame.Msg.GetProperty("Favorited").GetInt32());
|
||||||
|
Assert.Empty(hub.To(CapturingHubContext.PlayerGroup(other.PlayerId), "1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Kick_is_the_flat_moderation_block_with_the_reason_in_VoteKickReason()
|
||||||
|
{
|
||||||
|
var (factory, hub) = WithCapturingHub();
|
||||||
|
await using var scope = factory.Services.CreateAsyncScope();
|
||||||
|
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||||
|
|
||||||
|
await notifications.KickPlayerAsync(42, "Too loud", gameSessionId: 777);
|
||||||
|
|
||||||
|
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(42), "22"));
|
||||||
|
var msg = frame.Msg;
|
||||||
|
// The handler only shows a reason for ReportCategory 10 + !IsHostKick.
|
||||||
|
Assert.Equal(10, msg.GetProperty("ReportCategory").GetInt32());
|
||||||
|
Assert.False(msg.GetProperty("IsHostKick").GetBoolean());
|
||||||
|
Assert.Equal("Too loud", msg.GetProperty("VoteKickReason").GetString());
|
||||||
|
Assert.Equal(777, msg.GetProperty("GameSessionId").GetInt64());
|
||||||
|
Assert.Equal(0, msg.GetProperty("Duration").GetInt32());
|
||||||
|
Assert.False(msg.TryGetProperty("Reason", out _)); // the old nested 2020 shape
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NotificationService.LogoutReason.SessionTakeover, 2)]
|
||||||
|
[InlineData(NotificationService.LogoutReason.ForciblyLoggedOut, 3)]
|
||||||
|
[InlineData(NotificationService.LogoutReason.Banned, 4)]
|
||||||
|
public async Task Logout_reason_is_the_numeric_enum(NotificationService.LogoutReason reason, int wire)
|
||||||
|
{
|
||||||
|
var (factory, hub) = WithCapturingHub();
|
||||||
|
await using var scope = factory.Services.CreateAsyncScope();
|
||||||
|
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||||
|
|
||||||
|
await notifications.LogoutAsync(43, reason);
|
||||||
|
|
||||||
|
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(43), "6"));
|
||||||
|
var value = frame.Msg.GetProperty("Reason");
|
||||||
|
Assert.Equal(JsonValueKind.Number, value.ValueKind);
|
||||||
|
Assert.Equal(wire, value.GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Presence_for_subscribers_goes_to_the_subjects_presence_group_minus_excluded_connections()
|
||||||
|
{
|
||||||
|
var (factory, hub) = WithCapturingHub();
|
||||||
|
await using var scope = factory.Services.CreateAsyncScope();
|
||||||
|
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||||
|
|
||||||
|
await notifications.PresenceToSubscribersAsync(55, room: null, isOnline: true, statusVisibility: 0,
|
||||||
|
excludeConnectionIds: ["self-conn", "friend-conn"]);
|
||||||
|
|
||||||
|
var frame = Assert.Single(hub.To($"group:{NotifyHub.PresenceGroupForPlayer(55)}", "PresenceUpdate"));
|
||||||
|
Assert.Equal(["self-conn", "friend-conn"], frame.Excluded);
|
||||||
|
Assert.Equal(55, frame.Msg.GetProperty("playerId").GetInt32());
|
||||||
|
Assert.True(frame.Msg.GetProperty("isOnline").GetBoolean());
|
||||||
|
Assert.Equal(JsonValueKind.String, frame.Msg.GetProperty("appVersion").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"PlayerIds":[1,2,3]}""", new long[] { 1, 2, 3 })]
|
||||||
|
[InlineData("""{"playerIds":["4",5]}""", new long[] { 4, 5 })]
|
||||||
|
[InlineData("""[6,7]""", new long[] { 6, 7 })]
|
||||||
|
[InlineData("""{"PlayerIds":[]}""", new long[0])]
|
||||||
|
public void SubscribeToPlayers_payload_is_read_in_any_casing(string json, long[] expected)
|
||||||
|
{
|
||||||
|
Assert.True(NotifyHub.TryReadPlayerIds(JsonDocument.Parse(json).RootElement, out var ids));
|
||||||
|
Assert.Equal(expected.OrderBy(x => x), ids.OrderBy(x => x));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unreadable_SubscribeToPlayers_payload_is_rejected_not_read_as_empty()
|
||||||
|
{
|
||||||
|
Assert.False(NotifyHub.TryReadPlayerIds(JsonDocument.Parse("""{"Channels":[]}""").RootElement, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Consumable_mapping_push_has_exactly_the_clients_keys()
|
||||||
|
{
|
||||||
|
var wire = JsonSerializer.SerializeToElement(InventoryController.ConsumableMappingWire(new PlayerInventoryEntity
|
||||||
|
{
|
||||||
|
Id = 9,
|
||||||
|
ItemSlug = "some-consumable",
|
||||||
|
Quantity = 3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
var keys = wire.EnumerateObject().Select(p => p.Name).ToHashSet();
|
||||||
|
Assert.Equal(
|
||||||
|
new HashSet<string> { "Id", "ConsumableItemDesc", "Count", "InitialCount", "CreatedAt", "ActiveDurationMinutes", "IsActive", "IsTransferable" },
|
||||||
|
keys);
|
||||||
|
Assert.Equal(JsonValueKind.String, wire.GetProperty("ConsumableItemDesc").ValueKind);
|
||||||
|
Assert.Equal(3, wire.GetProperty("Count").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
private (WebApplicationFactory<Program> Factory, CapturingHubContext Hub) WithCapturingHub()
|
||||||
|
{
|
||||||
|
var hub = new CapturingHubContext();
|
||||||
|
var factory = _factory.WithWebHostBuilder(builder =>
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<IHubContext<NotifyHub>>();
|
||||||
|
services.AddSingleton<IHubContext<NotifyHub>>(hub);
|
||||||
|
}));
|
||||||
|
return (factory, hub);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient ApiClient(WebApplicationFactory<Program> factory, GameClientSession? session = null)
|
||||||
|
{
|
||||||
|
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||||
|
if (session is not null)
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Controllers.API.Avatar.V4;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire contract of the 2023-03-21 client's outfit / wardrobe menu, as read
|
||||||
|
/// from RecNet.Runtime ISIL: saved-outfit slots (BEPMJICDNDD via converter
|
||||||
|
/// IMMOJHBENFI), the custom-avatar-item gates the menu queries when it opens,
|
||||||
|
/// the locked-item lookup, the owned list's per-entry ids, and the gift
|
||||||
|
/// generate call the menu's gift flow makes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OutfitMenu2023Tests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public OutfitMenu2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Creation_allowed_gate_is_a_success_error_envelope()
|
||||||
|
{
|
||||||
|
// MPBLNLMCEDL.GGMDLMLJDDK parses DJMHAFPGLLN {Success, Error}
|
||||||
|
// (LMCCNLLHLCJ.txt:191-254); a bare boolean is what logged
|
||||||
|
// "Failed to query if local account can create Custom Avatar Items".
|
||||||
|
using var setup = ApiClient();
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
using var client = ApiClient(player);
|
||||||
|
|
||||||
|
var body = await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationAllowedForAccount");
|
||||||
|
Assert.Equal(JsonValueKind.Object, body.ValueKind);
|
||||||
|
Assert.Equal(JsonValueKind.True, body.GetProperty("Success").ValueKind);
|
||||||
|
Assert.Equal(JsonValueKind.Null, body.GetProperty("Error").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sibling_gates_and_min_price_are_bare_scalars()
|
||||||
|
{
|
||||||
|
using var client = ApiClient();
|
||||||
|
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationEnabled")).ValueKind);
|
||||||
|
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isRenderingEnabled")).ValueKind);
|
||||||
|
|
||||||
|
// <GetPricingConfig>b__69_0(Int32 minimumPrice) — a bare integer.
|
||||||
|
var min = await GetJsonAsync(client, "/api/customAvatarItems/v1/minPriceForPublicItem");
|
||||||
|
Assert.Equal(JsonValueKind.Number, min.ValueKind);
|
||||||
|
Assert.True(min.GetInt32() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Saved_outfit_round_trips_every_client_key()
|
||||||
|
{
|
||||||
|
using var setup = ApiClient();
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
using var client = ApiClient(player);
|
||||||
|
|
||||||
|
var shirt = Guid.NewGuid();
|
||||||
|
// Exactly what the client's writer (IMMOJHBENFI.txt:781-910) emits.
|
||||||
|
var outfit = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Slot"] = 3,
|
||||||
|
["PreviewImageName"] = "preview-3.jpg",
|
||||||
|
["OutfitSelections"] = "",
|
||||||
|
["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"a\",\"b\"]}",
|
||||||
|
["FaceFeatures"] = "{\"ver\":3}",
|
||||||
|
["SkinColor"] = "skin-guid",
|
||||||
|
["HairColor"] = "hair-guid",
|
||||||
|
["CustomAvatarItems"] = new[] { new { CustomAvatarItemId = shirt, BodyPart = 2 } },
|
||||||
|
};
|
||||||
|
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
|
||||||
|
|
||||||
|
var list = await GetJsonAsync(client, "/api/avatar/v3/saved");
|
||||||
|
var saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
|
||||||
|
Assert.Equal("preview-3.jpg", saved.GetProperty("PreviewImageName").GetString());
|
||||||
|
Assert.Equal("{\"v\":2,\"items\":[\"a\",\"b\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
|
||||||
|
Assert.Equal("{\"ver\":3}", saved.GetProperty("FaceFeatures").GetString());
|
||||||
|
Assert.Equal("skin-guid", saved.GetProperty("SkinColor").GetString());
|
||||||
|
Assert.Equal("hair-guid", saved.GetProperty("HairColor").GetString());
|
||||||
|
var items = saved.GetProperty("CustomAvatarItems");
|
||||||
|
Assert.Equal(1, items.GetArrayLength());
|
||||||
|
Assert.Equal(shirt, items[0].GetProperty("CustomAvatarItemId").GetGuid());
|
||||||
|
Assert.Equal(2, items[0].GetProperty("BodyPart").GetInt32());
|
||||||
|
|
||||||
|
// Overwriting the slot replaces it rather than adding a second row.
|
||||||
|
outfit["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"c\"]}";
|
||||||
|
outfit["CustomAvatarItems"] = Array.Empty<object>();
|
||||||
|
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
|
||||||
|
list = await GetJsonAsync(client, "/api/avatar/v3/saved");
|
||||||
|
saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
|
||||||
|
Assert.Equal("{\"v\":2,\"items\":[\"c\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
|
||||||
|
Assert.Equal(0, saved.GetProperty("CustomAvatarItems").GetArrayLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Gift_generate_accepts_the_clients_form_body()
|
||||||
|
{
|
||||||
|
// Live trace: this exact form body was answered 415 under [FromBody].
|
||||||
|
using var setup = ApiClient();
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
using var client = ApiClient(player);
|
||||||
|
|
||||||
|
using var response = await client.PostAsync("/api/avatar/v2/gifts/generate",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["GiftContext"] = "12000",
|
||||||
|
["IsGameGift"] = "True",
|
||||||
|
["Message"] = "Activity completed!",
|
||||||
|
}));
|
||||||
|
Assert.NotEqual(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
Assert.True(doc.RootElement.GetProperty("Id").GetInt64() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Locked_items_carry_the_full_owned_item_shape()
|
||||||
|
{
|
||||||
|
using var setup = ApiClient();
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
using var client = ApiClient(player);
|
||||||
|
|
||||||
|
HashSet<string> owned;
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var inv = await db.Avatars.Where(a => a.PlayerId == player.PlayerId)
|
||||||
|
.Select(a => a.InventoryJson).FirstAsync();
|
||||||
|
owned = (JsonSerializer.Deserialize<List<string>>(inv) ?? new())
|
||||||
|
.Select(d => d.Replace("wardrobe-", "").Split(',')[0])
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
var notOwned = AvatarItemsController.Catalog.Keys
|
||||||
|
.Where(g => Guid.TryParse(g, out _) && !owned.Contains(g)).Take(3).ToList();
|
||||||
|
Assert.Equal(3, notOwned.Count);
|
||||||
|
|
||||||
|
var query = string.Join("&", notOwned.Select(g => "desc=" + Uri.EscapeDataString(g + ",,,")));
|
||||||
|
var rows = await GetJsonAsync(client, "/api/avatar/v1/lockeditems?" + query);
|
||||||
|
Assert.Equal(3, rows.GetArrayLength());
|
||||||
|
var ids = new List<int>();
|
||||||
|
foreach (var row in rows.EnumerateArray())
|
||||||
|
{
|
||||||
|
// Same IFNGBPJGINL keys v4/items carries (CLIANCNHNIE reader).
|
||||||
|
Assert.Equal(JsonValueKind.String, row.GetProperty("TagList").ValueKind);
|
||||||
|
Assert.True(row.GetProperty("IsBaseAvatarItem").ValueKind is JsonValueKind.True or JsonValueKind.False);
|
||||||
|
ids.Add(row.GetProperty("AvatarItemId").GetInt32());
|
||||||
|
}
|
||||||
|
Assert.DoesNotContain(0, ids);
|
||||||
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Owning_a_base_item_and_its_colorways_yields_unique_ids()
|
||||||
|
{
|
||||||
|
using var setup = ApiClient();
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
// Two colorways of one base item that the runtime catalog knows.
|
||||||
|
var group = StoreService.ColorVariantCatalog.Values
|
||||||
|
.Where(v => !string.IsNullOrWhiteSpace(v.descriptor)
|
||||||
|
&& AvatarItemsController.Catalog.ContainsKey(v.item_guid))
|
||||||
|
.GroupBy(v => v.item_guid, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.First(g => g.Select(v => v.descriptor).Distinct().Count() >= 2);
|
||||||
|
var baseGuid = group.Key;
|
||||||
|
var variants = group.Select(v => v.descriptor).Distinct().Take(2).ToList();
|
||||||
|
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
|
||||||
|
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||||
|
inv.Add(baseGuid);
|
||||||
|
inv.AddRange(variants);
|
||||||
|
avatar.InventoryJson = JsonSerializer.Serialize(inv);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = ApiClient(player);
|
||||||
|
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||||
|
var sameBase = items.EnumerateArray()
|
||||||
|
.Where(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]
|
||||||
|
.Equals(baseGuid, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal(3, sameBase.Count);
|
||||||
|
|
||||||
|
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||||
|
Assert.DoesNotContain(0, ids);
|
||||||
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient ApiClient(GameClientSession? session = null)
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||||
|
if (session is not null)
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task PostJsonAsync(HttpClient client, string path, object body)
|
||||||
|
{
|
||||||
|
using var response = await client.PostAsync(path,
|
||||||
|
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||||
|
{
|
||||||
|
using var response = await client.GetAsync(path);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,6 +215,124 @@ public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Owned_items_carry_the_same_name_tooltip_and_rarity_as_their_shop_tile()
|
||||||
|
{
|
||||||
|
using var setup = ApiClient();
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||||
|
.SetAllAvatarItemsOwnedEnabledAsync(true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = ApiClient(player);
|
||||||
|
var owned = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||||
|
var shop = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||||
|
|
||||||
|
var tiles = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||||
|
foreach (var row in shop.GetProperty("StoreItems").EnumerateArray())
|
||||||
|
{
|
||||||
|
var drop = row.GetProperty("GiftDrop");
|
||||||
|
var desc = drop.GetProperty("AvatarItemDesc").GetString();
|
||||||
|
if (!string.IsNullOrEmpty(desc)) tiles.TryAdd(desc, drop);
|
||||||
|
}
|
||||||
|
|
||||||
|
var matched = 0;
|
||||||
|
foreach (var item in owned.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!tiles.TryGetValue(item.GetProperty("AvatarItemDesc").GetString()!, out var tile)) continue;
|
||||||
|
matched++;
|
||||||
|
Assert.Equal(tile.GetProperty("FriendlyName").GetString(), item.GetProperty("FriendlyName").GetString());
|
||||||
|
Assert.Equal(tile.GetProperty("Tooltip").GetString(), item.GetProperty("Tooltip").GetString());
|
||||||
|
Assert.Equal(tile.GetProperty("Rarity").GetInt32(), item.GetProperty("Rarity").GetInt32());
|
||||||
|
}
|
||||||
|
Assert.True(matched > 0, "no owned item matched a shop tile");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||||
|
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Base_avatar_items_are_only_the_custom_clothing_templates()
|
||||||
|
{
|
||||||
|
// The client flags every defaultbaseavataritems row as a custom-
|
||||||
|
// clothing template and leaves it out of the owned rebuild, so a
|
||||||
|
// normal item listed here showed a price even when owned.
|
||||||
|
using var anonymous = ApiClient();
|
||||||
|
var rows = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultbaseavataritems");
|
||||||
|
var byDesc = rows.EnumerateArray().ToDictionary(
|
||||||
|
r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0],
|
||||||
|
r => r);
|
||||||
|
Assert.Equal(2, byDesc.Count);
|
||||||
|
Assert.Equal(2184, byDesc["c5d70cb4-71dd-4fe4-b719-34fe2073c611"].GetProperty("AvatarItemId").GetInt32());
|
||||||
|
Assert.Equal(2918, byDesc["95a519de-f2cb-429c-b014-508477f20d42"].GetProperty("AvatarItemId").GetInt32());
|
||||||
|
Assert.All(byDesc.Values, r => Assert.True(r.GetProperty("IsBaseAvatarItem").GetBoolean()));
|
||||||
|
|
||||||
|
// Templates aren't sold or listed as owned clothing.
|
||||||
|
var shop = await GetJsonAsync(anonymous, "/api/storefronts/v3/giftdropstore/3");
|
||||||
|
var shopDescs = shop.GetProperty("StoreItems").EnumerateArray()
|
||||||
|
.Select(r => r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString() ?? "");
|
||||||
|
Assert.DoesNotContain(shopDescs, d => byDesc.ContainsKey(d.Split(',')[0]));
|
||||||
|
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||||
|
.SetAllAvatarItemsOwnedEnabledAsync(true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var owned = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
|
||||||
|
var ownedDescs = owned.EnumerateArray().Select(r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]).ToList();
|
||||||
|
Assert.DoesNotContain(ownedDescs, byDesc.ContainsKey);
|
||||||
|
Assert.Contains("3c2ed30c-ca2a-4f9f-9bfa-ffe561c4a686", ownedDescs); // 80sHipHopJacket
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||||
|
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beard_Close: one of the client's defaultUnlockedVariants.
|
||||||
|
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Default_unlocked_items_are_owned_by_every_player()
|
||||||
|
{
|
||||||
|
// The client replaces its unlocked set with each list it downloads,
|
||||||
|
// so v4/items must carry the free default items or they show a price.
|
||||||
|
using var setup = ApiClient();
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
using var client = ApiClient(player);
|
||||||
|
|
||||||
|
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||||
|
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||||
|
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
|
||||||
|
// A default item the starter inventory also holds appears once.
|
||||||
|
Assert.Equal(descs.Count, descs.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||||
|
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||||
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||||
|
|
||||||
|
var locked = await GetJsonAsync(client, $"/api/avatar/v1/lockeditems?avatarItemDescs={DefaultItemGuid},,,");
|
||||||
|
Assert.Equal(0, locked.GetArrayLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Default_unlocked_endpoint_serves_defaults_before_login()
|
||||||
|
{
|
||||||
|
using var anonymous = ApiClient();
|
||||||
|
var items = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
|
||||||
|
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||||
|
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(
|
||||||
|
DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.DefaultUnlockedGuids.Count,
|
||||||
|
descs.Count);
|
||||||
|
}
|
||||||
|
|
||||||
private HttpClient ApiClient(GameClientSession? session = null)
|
private HttpClient ApiClient(GameClientSession? session = null)
|
||||||
{
|
{
|
||||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Controllers.Match;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Presence must follow the player through every transition, for the player
|
||||||
|
/// themselves (the heartbeat) and for everyone else (bulk /player and the
|
||||||
|
/// PresenceUpdate push).
|
||||||
|
///
|
||||||
|
/// The heartbeat side is decided by the client's compare
|
||||||
|
/// (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): a cached instance with
|
||||||
|
/// a negative id — the offline room — is in sync ONLY with roomInstance null,
|
||||||
|
/// and anything else makes the client cancel what it is doing and chase the
|
||||||
|
/// server's room. matchmake/none is how the client tells us it went to that
|
||||||
|
/// offline room, so after it the heartbeat must stay null.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PresenceTransitionTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public PresenceTransitionTests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Leaving_to_the_offline_room_makes_the_heartbeat_report_no_room()
|
||||||
|
{
|
||||||
|
using var client = await SignedInMatchClientAsync();
|
||||||
|
|
||||||
|
// Enter the dorm for real: matchmake, then report the join.
|
||||||
|
var dormInstance = await MatchmakeDormAsync(client);
|
||||||
|
await ReportJoinAsync(client, dormInstance);
|
||||||
|
var before = await HeartbeatAsync(client);
|
||||||
|
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(before)));
|
||||||
|
|
||||||
|
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||||
|
Assert.True(none.IsSuccessStatusCode, $"POST /matchmake/none -> {(int)none.StatusCode}");
|
||||||
|
|
||||||
|
// Twice: the first beat must not re-seed a dorm the second one returns.
|
||||||
|
for (var i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
var beat = await HeartbeatAsync(client);
|
||||||
|
var room = RoomInstanceOf(beat);
|
||||||
|
Assert.True(room.ValueKind == JsonValueKind.Null,
|
||||||
|
$"""
|
||||||
|
After matchmake/none the client holds its offline room (instance -1)
|
||||||
|
and is only in sync with a null roomInstance. The heartbeat answered
|
||||||
|
with a room, which makes the client cancel the offline load and
|
||||||
|
chase it:
|
||||||
|
|
||||||
|
{beat.GetRawText()}
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Leaving_while_a_join_is_pending_abandons_the_join()
|
||||||
|
{
|
||||||
|
using var client = await SignedInMatchClientAsync();
|
||||||
|
|
||||||
|
// The client gave up on this dorm join (e.g. a Photon timeout) and fell
|
||||||
|
// back to the offline room before reporting a result.
|
||||||
|
await MatchmakeDormAsync(client);
|
||||||
|
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||||
|
Assert.True(none.IsSuccessStatusCode);
|
||||||
|
|
||||||
|
var beat = await HeartbeatAsync(client);
|
||||||
|
Assert.True(RoomInstanceOf(beat).ValueKind == JsonValueKind.Null,
|
||||||
|
$"heartbeat still echoed the abandoned join target: {beat.GetRawText()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Matchmaking_again_after_leaving_restores_the_room()
|
||||||
|
{
|
||||||
|
using var client = await SignedInMatchClientAsync();
|
||||||
|
|
||||||
|
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||||
|
Assert.True(none.IsSuccessStatusCode);
|
||||||
|
Assert.True(RoomInstanceOf(await HeartbeatAsync(client)).ValueKind == JsonValueKind.Null);
|
||||||
|
|
||||||
|
var dormInstance = await MatchmakeDormAsync(client);
|
||||||
|
await ReportJoinAsync(client, dormInstance);
|
||||||
|
|
||||||
|
var beat = await HeartbeatAsync(client);
|
||||||
|
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(beat)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Friends_see_a_player_in_the_offline_room_as_online_with_no_room()
|
||||||
|
{
|
||||||
|
using var subject = await SignedInMatchClientAsync();
|
||||||
|
using var viewer = await SignedInMatchClientAsync();
|
||||||
|
var subjectId = await PlayerIdOfAsync(subject);
|
||||||
|
|
||||||
|
var dormInstance = await MatchmakeDormAsync(subject);
|
||||||
|
await ReportJoinAsync(subject, dormInstance);
|
||||||
|
using var none = await subject.PostAsync("/matchmake/none", Form());
|
||||||
|
Assert.True(none.IsSuccessStatusCode);
|
||||||
|
|
||||||
|
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||||
|
Assert.True(row.GetProperty("isOnline").GetBoolean(),
|
||||||
|
$"a player sitting in the offline room is online: {row.GetRawText()}");
|
||||||
|
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Logging_out_shows_the_player_offline_and_roomless()
|
||||||
|
{
|
||||||
|
using var subject = await SignedInMatchClientAsync();
|
||||||
|
using var viewer = await SignedInMatchClientAsync();
|
||||||
|
var subjectId = await PlayerIdOfAsync(subject);
|
||||||
|
|
||||||
|
var dormInstance = await MatchmakeDormAsync(subject);
|
||||||
|
await ReportJoinAsync(subject, dormInstance);
|
||||||
|
Assert.True((await BulkPresenceAsync(viewer, subjectId)).GetProperty("isOnline").GetBoolean());
|
||||||
|
|
||||||
|
using var logout = await subject.PostAsync("/player/logout", Form());
|
||||||
|
Assert.True(logout.IsSuccessStatusCode);
|
||||||
|
|
||||||
|
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||||
|
Assert.False(row.GetProperty("isOnline").GetBoolean(), row.GetRawText());
|
||||||
|
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bulk_presence_reports_the_stored_status_visibility()
|
||||||
|
{
|
||||||
|
using var subject = await SignedInMatchClientAsync();
|
||||||
|
using var viewer = await SignedInMatchClientAsync();
|
||||||
|
var subjectId = await PlayerIdOfAsync(subject);
|
||||||
|
|
||||||
|
using var put = await subject.PutAsync("/player/statusvisibility",
|
||||||
|
Form(("statusVisibility", "2")));
|
||||||
|
Assert.True(put.IsSuccessStatusCode, $"PUT statusvisibility -> {(int)put.StatusCode}");
|
||||||
|
|
||||||
|
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||||
|
Assert.Equal(2, row.GetProperty("statusVisibility").GetInt32());
|
||||||
|
|
||||||
|
// And the player's own heartbeat agrees.
|
||||||
|
var beat = await HeartbeatAsync(subject);
|
||||||
|
Assert.Equal(2, beat.GetProperty("statusVisibility").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Presence_push_carries_online_state_independently_of_the_room()
|
||||||
|
{
|
||||||
|
var room = new RoomInstanceDto
|
||||||
|
{
|
||||||
|
RoomInstanceId = 42,
|
||||||
|
RoomId = 7,
|
||||||
|
Name = "DormRoom",
|
||||||
|
PhotonRoomId = "^dormroom_p1",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Online, no room: the offline-room state. Used to be sent as OFFLINE
|
||||||
|
// because isOnline was derived from the room.
|
||||||
|
var onlineNoRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, null, isOnline: true));
|
||||||
|
Assert.True(onlineNoRoom.GetProperty("isOnline").GetBoolean());
|
||||||
|
Assert.Equal(JsonValueKind.Null, onlineNoRoom.GetProperty("roomInstance").ValueKind);
|
||||||
|
|
||||||
|
// Offline never leaks a room, even if one is still cached.
|
||||||
|
var offline = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: false));
|
||||||
|
Assert.False(offline.GetProperty("isOnline").GetBoolean());
|
||||||
|
Assert.Equal(JsonValueKind.Null, offline.GetProperty("roomInstance").ValueKind);
|
||||||
|
|
||||||
|
// Online in a room: redacted join coordinates, visibility passed through,
|
||||||
|
// appVersion a STRING.
|
||||||
|
var inRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: true, statusVisibility: 1));
|
||||||
|
Assert.True(inRoom.GetProperty("isOnline").GetBoolean());
|
||||||
|
Assert.Equal(42, inRoom.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64());
|
||||||
|
Assert.Equal(string.Empty, inRoom.GetProperty("roomInstance").GetProperty("photonRoomId").GetString());
|
||||||
|
Assert.Equal(1, inRoom.GetProperty("statusVisibility").GetInt32());
|
||||||
|
Assert.Equal(JsonValueKind.String, inRoom.GetProperty("appVersion").ValueKind);
|
||||||
|
|
||||||
|
static JsonElement Msg(string payload)
|
||||||
|
{
|
||||||
|
var root = JsonDocument.Parse(payload).RootElement;
|
||||||
|
// String-keyed channel: the client registers "PresenceUpdate".
|
||||||
|
Assert.Equal("PresenceUpdate", root.GetProperty("Id").GetString());
|
||||||
|
return root.GetProperty("Msg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Presence_pushes_honour_the_players_status_visibility()
|
||||||
|
{
|
||||||
|
// Live PresenceUpdate frames must hide a player exactly as GET /player
|
||||||
|
// does, or "appear offline" leaks every room change to friends.
|
||||||
|
long[] friends = [10, 11, 12];
|
||||||
|
var favorited = new HashSet<long> { 11 };
|
||||||
|
|
||||||
|
var pub = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Public, favorited);
|
||||||
|
Assert.Equal(friends, pub.VisibleTo);
|
||||||
|
Assert.Empty(pub.HiddenFrom);
|
||||||
|
|
||||||
|
// Every recipient is already a friend, so FriendsOnly hides nobody.
|
||||||
|
var friendsOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FriendsOnly, favorited);
|
||||||
|
Assert.Equal(friends, friendsOnly.VisibleTo);
|
||||||
|
Assert.Empty(friendsOnly.HiddenFrom);
|
||||||
|
|
||||||
|
var favoritesOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FavoriteFriendsOnly, favorited);
|
||||||
|
Assert.Equal([11L], favoritesOnly.VisibleTo);
|
||||||
|
Assert.Equal([10L, 12L], favoritesOnly.HiddenFrom);
|
||||||
|
|
||||||
|
var offline = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Offline, favorited);
|
||||||
|
Assert.Empty(offline.VisibleTo);
|
||||||
|
Assert.Equal(friends, offline.HiddenFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<HttpClient> SignedInMatchClientAsync()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://match.{_factory.ApexDomain}");
|
||||||
|
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||||
|
client.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<long> PlayerIdOfAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
var beat = await HeartbeatAsync(client);
|
||||||
|
return beat.GetProperty("playerId").GetInt64();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> MatchmakeDormAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
using var resp = await client.PostAsync("/matchmake/dorm", Form(("MaxPersistenceVersion", "45")));
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(resp.IsSuccessStatusCode, $"POST /matchmake/dorm -> {(int)resp.StatusCode}: {body}");
|
||||||
|
var id = FindInstanceId(JsonDocument.Parse(body).RootElement);
|
||||||
|
Assert.False(string.IsNullOrEmpty(id), $"no roomInstanceId in the dorm reply: {body}");
|
||||||
|
return id!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ReportJoinAsync(HttpClient client, string instanceId)
|
||||||
|
{
|
||||||
|
using var resp = await client.PostAsync($"/roominstance/{instanceId}/reportjoinresult", Form(("result", "0")));
|
||||||
|
Assert.True(resp.IsSuccessStatusCode, $"reportjoinresult -> {(int)resp.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> HeartbeatAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
using var resp = await client.PostAsync("/player/heartbeat", Form(("LoginLock", Guid.NewGuid().ToString())));
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(resp.IsSuccessStatusCode, $"POST /player/heartbeat -> {(int)resp.StatusCode}: {body}");
|
||||||
|
return JsonDocument.Parse(body).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> BulkPresenceAsync(HttpClient viewer, long subjectId)
|
||||||
|
{
|
||||||
|
using var resp = await viewer.GetAsync($"/player?{subjectId}");
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(resp.IsSuccessStatusCode, $"GET /player -> {(int)resp.StatusCode}: {body}");
|
||||||
|
foreach (var row in JsonDocument.Parse(body).RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (row.GetProperty("playerId").GetInt64() == subjectId) return row.Clone();
|
||||||
|
}
|
||||||
|
Assert.Fail($"no row for {subjectId}: {body}");
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement RoomInstanceOf(JsonElement presence)
|
||||||
|
{
|
||||||
|
Assert.True(presence.TryGetProperty("roomInstance", out var room),
|
||||||
|
$"no roomInstance key: {presence.GetRawText()}");
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) =>
|
||||||
|
new(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
|
||||||
|
|
||||||
|
private static string? FindInstanceId(JsonElement element)
|
||||||
|
{
|
||||||
|
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return property.Value.ToString();
|
||||||
|
if (property.Value.ValueKind == JsonValueKind.Object
|
||||||
|
&& FindInstanceId(property.Value) is { } nested)
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactor
|
|||||||
var text = await resp.Content.ReadAsStringAsync();
|
var text = await resp.Content.ReadAsStringAsync();
|
||||||
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
||||||
using var json = JsonDocument.Parse(text);
|
using var json = JsonDocument.Parse(text);
|
||||||
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64();
|
var cloneId = json.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64();
|
||||||
|
|
||||||
await using var scope = _factory.Services.CreateAsyncScope();
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using DorkNet.Server.Controllers.Match;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-game profile edits for the 2023-03-21 client. Every edit goes through
|
||||||
|
/// OPMAPIOEIFG.OBAGJBBIFGB → PUT "account/me/" + suffix with a single
|
||||||
|
/// urlencoded field whose name differs from the suffix (displayEmoji,
|
||||||
|
/// pronounFlags, identityFlags, imageName). None of these routes existed, so
|
||||||
|
/// every edit failed with "Failed to modify <suffix>", and no account
|
||||||
|
/// DTO emitted the fields, so nothing showed up for anyone. Online-status
|
||||||
|
/// visibility (PUT player/statusvisibility) was persisted but GET /player
|
||||||
|
/// always reported Public and never hid the player.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProfileEdit2023Tests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public ProfileEdit2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Emoji_edit_persists_and_is_visible_to_other_players()
|
||||||
|
{
|
||||||
|
var (owner, viewer) = await TwoPlayersAsync();
|
||||||
|
using var ownerClient = AccountsClient(owner);
|
||||||
|
using var viewerClient = AccountsClient(viewer);
|
||||||
|
|
||||||
|
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "🤪");
|
||||||
|
|
||||||
|
var me = await GetJsonAsync(ownerClient, "/account/me");
|
||||||
|
Assert.Equal("🤪", me.GetProperty("displayEmoji").GetString());
|
||||||
|
|
||||||
|
var seen = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}");
|
||||||
|
Assert.Equal("🤪", seen.GetProperty("displayEmoji").GetString());
|
||||||
|
|
||||||
|
// Empty clears it.
|
||||||
|
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "");
|
||||||
|
me = await GetJsonAsync(ownerClient, "/account/me");
|
||||||
|
Assert.Equal("", me.GetProperty("displayEmoji").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Emoji_edit_rejects_non_whitelisted_and_canonicalizes_variation_selector()
|
||||||
|
{
|
||||||
|
var (owner, _) = await TwoPlayersAsync();
|
||||||
|
using var client = AccountsClient(owner);
|
||||||
|
|
||||||
|
using (var bad = await PutFormAsync(client, "/account/me/emoji", "displayEmoji", "not-an-emoji"))
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||||
|
|
||||||
|
// "✌" without U+FE0F resolves to the list's "✌️".
|
||||||
|
await PutFormOkAsync(client, "/account/me/emoji", "displayEmoji", "✌");
|
||||||
|
var me = await GetJsonAsync(client, "/account/me");
|
||||||
|
Assert.Equal("✌️", me.GetProperty("displayEmoji").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pronouns_identity_flags_and_banner_round_trip_on_every_account_read()
|
||||||
|
{
|
||||||
|
var (owner, viewer) = await TwoPlayersAsync();
|
||||||
|
using var ownerClient = AccountsClient(owner);
|
||||||
|
using var viewerClient = AccountsClient(viewer);
|
||||||
|
|
||||||
|
await PutFormOkAsync(ownerClient, "/account/me/personalpronouns", "pronounFlags", "5");
|
||||||
|
await PutFormOkAsync(ownerClient, "/account/me/identityflags", "identityFlags", "3");
|
||||||
|
await PutFormOkAsync(ownerClient, "/account/me/bannerimage", "imageName", "banner-test.jpg");
|
||||||
|
|
||||||
|
void AssertProfile(JsonElement a)
|
||||||
|
{
|
||||||
|
Assert.Equal(5, a.GetProperty("personalPronouns").GetInt32());
|
||||||
|
Assert.Equal(3, a.GetProperty("identityFlags").GetInt32());
|
||||||
|
Assert.Equal("banner-test.jpg", a.GetProperty("bannerImage").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
AssertProfile(await GetJsonAsync(ownerClient, "/account/me"));
|
||||||
|
AssertProfile(await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}"));
|
||||||
|
|
||||||
|
var bulk = await GetJsonAsync(viewerClient, $"/account/bulk?id={owner.PlayerId}");
|
||||||
|
AssertProfile(bulk.EnumerateArray().Single(a => a.GetProperty("accountId").GetInt64() == owner.PlayerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Flag_edits_reject_non_numeric_values()
|
||||||
|
{
|
||||||
|
var (owner, _) = await TwoPlayersAsync();
|
||||||
|
using var client = AccountsClient(owner);
|
||||||
|
|
||||||
|
using (var r = await PutFormAsync(client, "/account/me/personalpronouns", "pronounFlags", "HeHim"))
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||||
|
using (var r = await PutFormAsync(client, "/account/me/identityflags", "identityFlags", "-1"))
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Self_account_reports_username_changes_remaining()
|
||||||
|
{
|
||||||
|
var (owner, _) = await TwoPlayersAsync();
|
||||||
|
using var client = AccountsClient(owner);
|
||||||
|
var me = await GetJsonAsync(client, "/account/me");
|
||||||
|
// Absent/0 makes the settings UI say no username changes are left.
|
||||||
|
Assert.True(me.GetProperty("availableUsernameChanges").GetInt32() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bio_edit_round_trips_through_public_bio_lookup()
|
||||||
|
{
|
||||||
|
var (owner, viewer) = await TwoPlayersAsync();
|
||||||
|
using var ownerClient = AccountsClient(owner);
|
||||||
|
using var viewerClient = AccountsClient(viewer);
|
||||||
|
|
||||||
|
await PutFormOkAsync(ownerClient, "/account/me/bio", "bio", "hello from the profile");
|
||||||
|
var bio = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}/bio");
|
||||||
|
Assert.Equal("hello from the profile", bio.GetProperty("bio").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Status_visibility_is_reported_to_self_and_hides_appear_offline_player()
|
||||||
|
{
|
||||||
|
var (owner, viewer) = await TwoPlayersAsync();
|
||||||
|
PutOnline(owner.PlayerId);
|
||||||
|
|
||||||
|
using var ownerMatch = MatchClient(owner);
|
||||||
|
using var viewerMatch = MatchClient(viewer);
|
||||||
|
|
||||||
|
// Baseline: Public → viewer sees owner online.
|
||||||
|
var before = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||||
|
Assert.True(before.GetProperty("isOnline").GetBoolean());
|
||||||
|
|
||||||
|
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "3");
|
||||||
|
|
||||||
|
// Self keeps its own choice (the dialog reads the cached self presence).
|
||||||
|
var self = await PresenceOfAsync(ownerMatch, owner.PlayerId);
|
||||||
|
Assert.Equal(3, self.GetProperty("statusVisibility").GetInt32());
|
||||||
|
Assert.True(self.GetProperty("isOnline").GetBoolean());
|
||||||
|
|
||||||
|
// Others see an offline player with no room.
|
||||||
|
var other = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||||
|
Assert.False(other.GetProperty("isOnline").GetBoolean());
|
||||||
|
Assert.Equal(JsonValueKind.Null, other.GetProperty("roomInstance").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Friends_only_visibility_shows_friends_and_hides_strangers()
|
||||||
|
{
|
||||||
|
var (owner, friend) = await TwoPlayersAsync();
|
||||||
|
using var setup = AccountsClient();
|
||||||
|
var stranger = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
PutOnline(owner.PlayerId);
|
||||||
|
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
db.Relationships.Add(new RelationshipEntity
|
||||||
|
{
|
||||||
|
RequesterId = owner.PlayerId,
|
||||||
|
TargetId = friend.PlayerId,
|
||||||
|
Status = RelationshipStatus.Friend,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ownerMatch = MatchClient(owner);
|
||||||
|
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "1");
|
||||||
|
|
||||||
|
using var friendMatch = MatchClient(friend);
|
||||||
|
using var strangerMatch = MatchClient(stranger);
|
||||||
|
Assert.True((await PresenceOfAsync(friendMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||||
|
Assert.False((await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||||
|
Assert.Equal(1, (await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("statusVisibility").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Presence_pref_key_matches_status_visibility_writer()
|
||||||
|
{
|
||||||
|
// MatchPlayerController writes the pref; PlayerPresenceController reads it.
|
||||||
|
var field = typeof(MatchPlayerController).GetField(
|
||||||
|
"PrefStatusVisibility",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
Assert.NotNull(field);
|
||||||
|
Assert.Equal(PlayerPresenceController.PrefStatusVisibility, (string)field!.GetRawConstantValue()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void PutOnline(long playerId)
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||||
|
presence.SetRoom(playerId, new RoomInstanceDto
|
||||||
|
{
|
||||||
|
RoomInstanceId = 900_000 + playerId,
|
||||||
|
RoomId = 1,
|
||||||
|
SubRoomId = 0,
|
||||||
|
PhotonRoomId = $"^profiletest{playerId}",
|
||||||
|
PhotonRegionId = "us",
|
||||||
|
MaxCapacity = 8,
|
||||||
|
});
|
||||||
|
presence.MarkActive(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> PresenceOfAsync(HttpClient client, long playerId)
|
||||||
|
{
|
||||||
|
var list = await GetJsonAsync(client, $"/player?{playerId}");
|
||||||
|
return list.EnumerateArray().Single(p => p.GetProperty("playerId").GetInt64() == playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(GameClientSession, GameClientSession)> TwoPlayersAsync()
|
||||||
|
{
|
||||||
|
using var setup = AccountsClient();
|
||||||
|
var a = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var b = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
return (a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HttpResponseMessage> PutFormAsync(HttpClient client, string path, string field, string value)
|
||||||
|
=> await client.PutAsync(path, new FormUrlEncodedContent(new Dictionary<string, string> { [field] = value }));
|
||||||
|
|
||||||
|
private static async Task PutFormOkAsync(HttpClient client, string path, string field, string value)
|
||||||
|
{
|
||||||
|
using var response = await PutFormAsync(client, path, field, value);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"PUT {path} → {(int)response.StatusCode}: {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient AccountsClient(GameClientSession? session = null) => Client("accounts", session);
|
||||||
|
|
||||||
|
private HttpClient MatchClient(GameClientSession session) => Client("match", session);
|
||||||
|
|
||||||
|
private HttpClient Client(string host, GameClientSession? session)
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||||
|
if (session is not null)
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||||
|
{
|
||||||
|
using var response = await client.GetAsync(path);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"GET {path} → {(int)response.StatusCode}: {body}");
|
||||||
|
using var document = JsonDocument.Parse(body);
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Quest client patched without its own Meta app has no platform session,
|
||||||
|
/// so every headset reports the same placeholder platform user id ("1").
|
||||||
|
/// Accounts must stay keyed by device id: two headsets get two accounts, a
|
||||||
|
/// headset gets its own account back on the next boot, and the placeholder is
|
||||||
|
/// never stored or matched by the remembered-logins lookup — otherwise every
|
||||||
|
/// Quest player would be offered every other Quest player's account.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QuestPlaceholderPlatformIdTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
// RecNet platform value the 2023 Quest client sends on /connect/token.
|
||||||
|
// The fix does not depend on it; any platform with a placeholder id is
|
||||||
|
// handled the same way.
|
||||||
|
private const string QuestPlatform = "1";
|
||||||
|
private const string PlaceholderPlatformId = "1";
|
||||||
|
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public QuestPlaceholderPlatformIdTests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, true)]
|
||||||
|
[InlineData("", true)]
|
||||||
|
[InlineData(" ", true)]
|
||||||
|
[InlineData("0", true)]
|
||||||
|
[InlineData("1", true)]
|
||||||
|
[InlineData(" 1 ", true)]
|
||||||
|
[InlineData("10", false)]
|
||||||
|
[InlineData("2173678582678296", false)]
|
||||||
|
public void Placeholder_platform_ids_are_recognised(string? platformId, bool expected)
|
||||||
|
=> Assert.Equal(expected, PlayerService.IsPlaceholderPlatformId(platformId));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Two_headsets_with_the_placeholder_platform_id_get_separate_accounts()
|
||||||
|
{
|
||||||
|
using var client = Client("auth");
|
||||||
|
var headsetA = $"quest-headset-a-{Guid.NewGuid():N}";
|
||||||
|
var headsetB = $"quest-headset-b-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
var a = await CreateAccountAsync(client, headsetA);
|
||||||
|
var b = await CreateAccountAsync(client, headsetB);
|
||||||
|
Assert.NotEqual(a, b);
|
||||||
|
|
||||||
|
// Same headset, next boot: its own account comes back.
|
||||||
|
var aAgain = await CreateAccountAsync(client, headsetA);
|
||||||
|
Assert.Equal(a, aAgain);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Placeholder_platform_id_never_lists_remembered_logins()
|
||||||
|
{
|
||||||
|
using var client = Client("auth");
|
||||||
|
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
|
||||||
|
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
using var response = await client.GetAsync(
|
||||||
|
$"/cachedlogin/forplatformid/{QuestPlatform}/{PlaceholderPlatformId}");
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode,
|
||||||
|
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
|
||||||
|
|
||||||
|
using var json = JsonDocument.Parse(body);
|
||||||
|
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
|
||||||
|
Assert.Equal(0, json.RootElement.GetArrayLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_real_platform_id_still_lists_its_remembered_login()
|
||||||
|
{
|
||||||
|
using var client = Client("auth");
|
||||||
|
var realPlatformId = $"{Random.Shared.NextInt64(10_000_000_000, 99_999_999_999)}";
|
||||||
|
var id = await CreateAccountAsync(client, $"quest-meta-{Guid.NewGuid():N}", realPlatformId);
|
||||||
|
|
||||||
|
using var response = await client.GetAsync(
|
||||||
|
$"/cachedlogin/forplatformid/{QuestPlatform}/{realPlatformId}");
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode,
|
||||||
|
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
|
||||||
|
Assert.Contains(id.ToString(), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 2023 client's account-creation request (LBNJFPOLCDL: grant_type,
|
||||||
|
// client_id, platform, platform_id, device_id, ...). No username/password,
|
||||||
|
// so the server takes the device-id path.
|
||||||
|
private async Task<long> CreateAccountAsync(
|
||||||
|
HttpClient client, string deviceId, string platformId = PlaceholderPlatformId)
|
||||||
|
{
|
||||||
|
var form = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "create_account",
|
||||||
|
["client_id"] = "recroom",
|
||||||
|
["platform"] = QuestPlatform,
|
||||||
|
["platform_id"] = platformId,
|
||||||
|
["device_id"] = deviceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token")
|
||||||
|
{
|
||||||
|
Content = new FormUrlEncodedContent(form),
|
||||||
|
};
|
||||||
|
request.Headers.Accept.ParseAdd("application/json");
|
||||||
|
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.17");
|
||||||
|
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(request);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode,
|
||||||
|
$"POST /connect/token -> {(int)response.StatusCode}: {body}");
|
||||||
|
|
||||||
|
using var json = JsonDocument.Parse(body);
|
||||||
|
var accessToken = json.RootElement.GetProperty("access_token").GetString()!;
|
||||||
|
|
||||||
|
using var userInfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo");
|
||||||
|
userInfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||||
|
using var userInfoResponse = await client.SendAsync(userInfo);
|
||||||
|
userInfoResponse.EnsureSuccessStatusCode();
|
||||||
|
using var userJson = JsonDocument.Parse(await userInfoResponse.Content.ReadAsStringAsync());
|
||||||
|
return long.Parse(userJson.RootElement.GetProperty("sub").GetString()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient Client(string host)
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes whose client method returns the untyped promise (<c>LDGADANDBIO</c>)
|
||||||
|
/// but whose reply is still parsed — as <c>DJMHAFPGLLN</c>, the
|
||||||
|
/// <c>{Success, Error}</c> RecNetResult — by <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
|
||||||
|
/// (ExpandRecNetResult, RecNet.Runtime/BFDGEKKNAPB.txt:353).
|
||||||
|
///
|
||||||
|
/// The failure modes are silent server-side: an empty 200 parses to null and
|
||||||
|
/// null-derefs, so the player sees "Failed to …" after the change was applied;
|
||||||
|
/// a body without <c>Success</c> parses as a failure whose <c>Error</c> is null,
|
||||||
|
/// and <c>Promise.Rejected(null)</c> throws "Error message cannot be null".
|
||||||
|
/// So the assertion that matters is the body, read the way the client's
|
||||||
|
/// formatter reads it (LMCCNLLHLCJ.txt: Success/success, Error/error).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RecNetResultEnvelopeTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public RecNetResultEnvelopeTests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Club_member_writes_answer_a_success_result_not_an_empty_body()
|
||||||
|
{
|
||||||
|
using var setup = Client("clubs");
|
||||||
|
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var joiner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var clubId = await SeedOpenClubAsync(owner.PlayerId);
|
||||||
|
|
||||||
|
using var joinerClient = Client("clubs", joiner);
|
||||||
|
// requesttojoin is verb 3 (PUT) through IKMMOCKDKAF.JNFEEDLCGHH, form-encoded.
|
||||||
|
using var join = await joinerClient.PutAsync(
|
||||||
|
$"/club/{clubId}/members/requesttojoin", new FormUrlEncodedContent([]));
|
||||||
|
await AssertRecNetSuccessAsync(join, "requesttojoin");
|
||||||
|
|
||||||
|
using var leave = await joinerClient.PostAsync(
|
||||||
|
$"/club/{clubId}/members/leave", new FormUrlEncodedContent([]));
|
||||||
|
await AssertRecNetSuccessAsync(leave, "leave");
|
||||||
|
|
||||||
|
using var clearHome = await joinerClient.DeleteAsync("/club/home/me");
|
||||||
|
await AssertRecNetSuccessAsync(clearHome, "DELETE club/home/me");
|
||||||
|
|
||||||
|
using var ownerClient = Client("clubs", owner);
|
||||||
|
using var delete = await ownerClient.DeleteAsync($"/club/{clubId}");
|
||||||
|
await AssertRecNetSuccessAsync(delete, "DELETE club/{id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_markers_and_privacy_toggle_answer_a_success_result()
|
||||||
|
{
|
||||||
|
using var setup = Client("api");
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
using var client = Client("api", player);
|
||||||
|
|
||||||
|
using var commentRead = await client.PutAsync("/comments/read/1/1", content: null);
|
||||||
|
await AssertRecNetSuccessAsync(commentRead, "PUT comments/read");
|
||||||
|
|
||||||
|
using var accounts = Client("accounts", player);
|
||||||
|
using var privacy = await accounts.PutAsync(
|
||||||
|
"/accountprivacysettings/recenthistoryvisibility",
|
||||||
|
new FormUrlEncodedContent([new("isRecentHistoryVisible", "false")]));
|
||||||
|
await AssertRecNetSuccessAsync(privacy, "PUT recenthistoryvisibility");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_refused_warning_carries_an_error_message()
|
||||||
|
{
|
||||||
|
using var setup = Client("api");
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
using var client = Client("api", player);
|
||||||
|
|
||||||
|
using var response = await client.PostAsync("/api/playerwarnings",
|
||||||
|
new FormUrlEncodedContent([new("WarnedPlayerId", player.PlayerId.ToString())]));
|
||||||
|
var root = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.False(ReadSuccess(root));
|
||||||
|
// Without Error the client's Promise.Rejected(null) throws instead of
|
||||||
|
// surfacing the refusal.
|
||||||
|
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_token_is_a_bare_string_redeemed_once_for_its_own_account()
|
||||||
|
{
|
||||||
|
using var setup = Client("auth");
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
using var auth = Client("auth", player);
|
||||||
|
|
||||||
|
using var mint = await auth.PostAsync("/account/me/createlogintoken", content: null);
|
||||||
|
var raw = await mint.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(mint.IsSuccessStatusCode, raw);
|
||||||
|
// FGLDKEJLAKB<String> LGKLFFPACGL(): a JSON string, not an object.
|
||||||
|
var token = JsonSerializer.Deserialize<string>(raw);
|
||||||
|
Assert.False(string.IsNullOrEmpty(token));
|
||||||
|
|
||||||
|
using var anon = Client("auth");
|
||||||
|
var form = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "login_token",
|
||||||
|
["account_id"] = player.PlayerId.ToString(),
|
||||||
|
["login_token"] = token!,
|
||||||
|
};
|
||||||
|
using var redeemed = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||||
|
var redeemedBody = await redeemed.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(redeemed.IsSuccessStatusCode, redeemedBody);
|
||||||
|
Assert.Equal(player.PlayerId, await SubjectOfAsync(redeemedBody));
|
||||||
|
|
||||||
|
using var again = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Device_code_poll_waits_for_remoteauth_approval_then_signs_in_the_approver()
|
||||||
|
{
|
||||||
|
using var anon = Client("auth");
|
||||||
|
var approver = await GameClientSessionFactory.CreateAsync(anon, _factory.ApexDomain);
|
||||||
|
|
||||||
|
using var start = await anon.PostAsync("/connect/deviceauthorization", content: null);
|
||||||
|
var started = JsonDocument.Parse(await start.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
var deviceCode = started.GetProperty("device_code").GetString()!;
|
||||||
|
var userCode = started.GetProperty("user_code").GetString()!;
|
||||||
|
|
||||||
|
var poll = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
["device_code"] = deviceCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Before approval the client must be told to keep polling: a 400
|
||||||
|
// whose error is authorization_pending (JAEKFNKDNCP.txt:97-107).
|
||||||
|
using (var pending = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll)))
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, pending.StatusCode);
|
||||||
|
var body = JsonDocument.Parse(await pending.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.Equal("authorization_pending", body.GetProperty("error").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
using var approverClient = Client("auth", approver);
|
||||||
|
using (var bad = await approverClient.PostAsync("/account/me/remoteauth",
|
||||||
|
new FormUrlEncodedContent([new("code", "NOPE0000")])))
|
||||||
|
{
|
||||||
|
var root = JsonDocument.Parse(await bad.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.False(ReadSuccess(root));
|
||||||
|
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var approve = await approverClient.PostAsync("/account/me/remoteauth",
|
||||||
|
new FormUrlEncodedContent([new("code", userCode.ToLowerInvariant())]));
|
||||||
|
await AssertRecNetSuccessAsync(approve, "remoteauth");
|
||||||
|
|
||||||
|
using var granted = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||||
|
var grantedBody = await granted.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(granted.IsSuccessStatusCode, grantedBody);
|
||||||
|
Assert.Equal(approver.PlayerId, await SubjectOfAsync(grantedBody));
|
||||||
|
|
||||||
|
// Single use.
|
||||||
|
using var reused = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, reused.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Gameconfig_carries_the_play_menu_carousel_ordering_in_the_parsed_format()
|
||||||
|
{
|
||||||
|
using var client = Client("api");
|
||||||
|
var rows = JsonDocument.Parse(await client.GetStringAsync("/api/gameconfigs/v1/all")).RootElement;
|
||||||
|
var row = rows.EnumerateArray().FirstOrDefault(r =>
|
||||||
|
string.Equals(Prop(r, "Key"), "PlayMenuCarouselOrdering_1", StringComparison.Ordinal));
|
||||||
|
Assert.NotEqual(JsonValueKind.Undefined, row.ValueKind);
|
||||||
|
|
||||||
|
// PlayMenuCarouselModel.TryGetDisplayPriorityMap: Split(';'), then each
|
||||||
|
// entry Split('=') must give exactly 2 parts with an Int32 priority, or
|
||||||
|
// the entry is logged as "Failed to parse priority mapping".
|
||||||
|
var entries = Prop(row, "Value")!.Split(';');
|
||||||
|
Assert.All(entries, e =>
|
||||||
|
{
|
||||||
|
var parts = e.Split('=');
|
||||||
|
Assert.Equal(2, parts.Length);
|
||||||
|
Assert.True(int.TryParse(parts[1].Trim(), out _), e);
|
||||||
|
});
|
||||||
|
Assert.Contains(entries, e => e.StartsWith("hotrooms=", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Server_strings_download_answers_an_empty_table()
|
||||||
|
{
|
||||||
|
using var client = Client("strings");
|
||||||
|
using var response = await client.GetAsync("/strings?locale=en-US&scopePrefix=client.&eTag=");
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, body);
|
||||||
|
Assert.Equal(JsonValueKind.Object, JsonDocument.Parse(body).RootElement.ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PUT playerevents/v2/{id}/multiinstance sends the two broadcast
|
||||||
|
/// permission fields as boxed enums, i.e. member names (BNDIAONDFFF.txt:947
|
||||||
|
/// falls back to Object.ToString). Parsing them as ints stored 0 every time.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("RoomOwners", 256)]
|
||||||
|
[InlineData("All", int.MaxValue)]
|
||||||
|
[InlineData("None", 0)]
|
||||||
|
[InlineData("256", 256)]
|
||||||
|
[InlineData("roomowners, None", 256)]
|
||||||
|
public void Broadcast_permission_names_parse_to_their_values(string raw, int expected) =>
|
||||||
|
Assert.Equal(expected,
|
||||||
|
DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController.ParseBroadcastPermissions(raw));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_broadcast_permission_name_keeps_the_stored_value() =>
|
||||||
|
Assert.Null(DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController
|
||||||
|
.ParseBroadcastPermissions("SomethingElse"));
|
||||||
|
|
||||||
|
private async Task<long> SeedOpenClubAsync(long ownerId)
|
||||||
|
{
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var club = new ClubEntity
|
||||||
|
{
|
||||||
|
Name = $"Env{Guid.NewGuid():N}"[..16],
|
||||||
|
CreatorPlayerId = ownerId,
|
||||||
|
Joinability = 0, // Open
|
||||||
|
};
|
||||||
|
db.Clubs.Add(club);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return club.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<long> SubjectOfAsync(string tokenBody)
|
||||||
|
{
|
||||||
|
var access = JsonDocument.Parse(tokenBody).RootElement.GetProperty("access_token").GetString();
|
||||||
|
using var client = Client("auth");
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access);
|
||||||
|
var info = JsonDocument.Parse(await client.GetStringAsync("/connect/userinfo")).RootElement;
|
||||||
|
return long.Parse(info.GetProperty("sub").GetString()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AssertRecNetSuccessAsync(HttpResponseMessage response, string what)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"{what} -> {(int)response.StatusCode}: {body}");
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(body), $"{what} answered an empty body");
|
||||||
|
var root = JsonDocument.Parse(body).RootElement;
|
||||||
|
Assert.True(ReadSuccess(root), $"{what} body has no Success=true: {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client formatter registers exactly these spellings.
|
||||||
|
private static bool ReadSuccess(JsonElement root) =>
|
||||||
|
(root.TryGetProperty("Success", out var s) || root.TryGetProperty("success", out s))
|
||||||
|
&& s.ValueKind == JsonValueKind.True;
|
||||||
|
|
||||||
|
private static string? ReadError(JsonElement root) =>
|
||||||
|
root.TryGetProperty("Error", out var e) || root.TryGetProperty("error", out e)
|
||||||
|
? e.ValueKind == JsonValueKind.String ? e.GetString() : null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string? Prop(JsonElement row, string name) =>
|
||||||
|
row.TryGetProperty(name, out var v) || row.TryGetProperty(char.ToLowerInvariant(name[0]) + name[1..], out v)
|
||||||
|
? v.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private HttpClient Client(string host, GameClientSession? session = null)
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||||
|
if (session is not null)
|
||||||
|
client.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,13 +10,15 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
namespace DorkNet.Server.Tests;
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and deserialises the
|
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and reads the reply
|
||||||
/// reply into <c>FGCPNAACHIK</c> — the SAME type <c>GET rooms/{id}</c> returns
|
/// as the <c>{success, value, error}</c> envelope (the same RecNet rooms helper
|
||||||
/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
|
/// as "save room"), whose <c>value</c> is <c>FGCPNAACHIK</c> — the SAME type
|
||||||
|
/// <c>GET rooms/{id}</c> returns (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
|
||||||
/// <c>Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
|
/// <c>Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
|
||||||
/// with the route literal on :4802).
|
/// with the route literal on :4802). A bare room with no <c>success</c> is
|
||||||
|
/// rejected with an empty error.
|
||||||
///
|
///
|
||||||
/// So the two responses must be shape-identical. When they drift, the server
|
/// So <c>value</c> and the GET response must be shape-identical. When they drift, the server
|
||||||
/// still answers 200 and the client throws while reading it — and because the
|
/// 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
|
/// 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
|
/// thread seconds later as a bare "Failed to copy room: Exception of type
|
||||||
@@ -63,8 +65,12 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
|||||||
}
|
}
|
||||||
|
|
||||||
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
|
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
|
||||||
var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
var cloneEnvelope = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||||
|
// The client reads clone through the {success, value, error} envelope.
|
||||||
|
Assert.True(cloneEnvelope.GetProperty("success").GetBoolean(), cloneEnvelope.ToString());
|
||||||
|
Assert.Equal("", cloneEnvelope.GetProperty("error").GetString());
|
||||||
|
var cloneBody = cloneEnvelope.GetProperty("value");
|
||||||
|
|
||||||
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||||
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||||
@@ -157,7 +163,7 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
|||||||
|
|
||||||
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||||
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
var cloneId = clone.GetProperty("value").GetProperty("RoomId").GetInt64();
|
||||||
|
|
||||||
await using (var scope = factory.Services.CreateAsyncScope())
|
await using (var scope = factory.Services.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
@@ -203,6 +209,24 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
|||||||
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
|
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clone_refusal_is_a_200_envelope_with_a_message()
|
||||||
|
{
|
||||||
|
using var client = Client();
|
||||||
|
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||||
|
client.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
|
||||||
|
// No room has this id, so the clone is refused; the client shows
|
||||||
|
// `error`, which a bare 404 would leave empty.
|
||||||
|
var envelope = await ReadJsonAsync(client, HttpMethod.Post, "/rooms/987654321987/clone",
|
||||||
|
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", "NoSuchSource")]));
|
||||||
|
|
||||||
|
Assert.False(envelope.GetProperty("success").GetBoolean());
|
||||||
|
Assert.False(string.IsNullOrEmpty(envelope.GetProperty("error").GetString()));
|
||||||
|
Assert.Equal(JsonValueKind.Null, envelope.GetProperty("value").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<JsonElement> ReadJsonAsync(
|
private static async Task<JsonElement> ReadJsonAsync(
|
||||||
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
|
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Publish room" in the 2023 client: RoomModel.TryPublishRoom →
|
||||||
|
/// <c>PUT roomserver/rooms/{id}/accessibility</c> with form
|
||||||
|
/// <c>accessibility=1</c> (boxed Int32, NLDBPDCNNCF_NestedType_OMHIIEAKHCH),
|
||||||
|
/// typed <c>Task<FGCPNAACHIK></c>. The button only exists when
|
||||||
|
/// get_CanPublishRoom holds: role ≥ CoOwner, not a dorm, <b>not IsRRO</b>,
|
||||||
|
/// not already public. A clone of a Rec Room Original template used to be
|
||||||
|
/// advertised as IsRRO and so could never be published.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoomPublish2023Tests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public RoomPublish2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_system_owned_originals_are_RRO_on_the_wire()
|
||||||
|
{
|
||||||
|
Assert.True(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = PlayerService.SystemAccountId }));
|
||||||
|
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = 4242 }));
|
||||||
|
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = false, CreatorPlayerId = PlayerService.SystemAccountId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clone_of_an_original_is_not_RRO_and_can_be_published_by_owner_and_co_owner()
|
||||||
|
{
|
||||||
|
using var setup = Client("api");
|
||||||
|
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var coOwner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var moderator = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
var templateId = 9_100_000 + Random.Shared.Next(1, 99_999);
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
db.Rooms.Add(new RoomEntity
|
||||||
|
{
|
||||||
|
Id = templateId,
|
||||||
|
Name = $"Tmpl{Guid.NewGuid():N}"[..18],
|
||||||
|
CreatorPlayerId = PlayerService.SystemAccountId,
|
||||||
|
IsAGRoom = true,
|
||||||
|
Accessibility = 1,
|
||||||
|
CloningAllowed = false,
|
||||||
|
ImageName = "image_RecCenter.png",
|
||||||
|
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||||
|
});
|
||||||
|
db.RoomScenes.Add(new RoomSceneEntity { RoomId = templateId, OrderIndex = 0, Name = "Home" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ownerRooms = Client("rooms", owner);
|
||||||
|
var template = await GetJsonAsync(ownerRooms, $"/roomserver/rooms/{templateId}");
|
||||||
|
Assert.True(template.GetProperty("IsRRO").GetBoolean());
|
||||||
|
|
||||||
|
using var cloneResp = await ownerRooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Pub{Guid.NewGuid():N}"[..20] }));
|
||||||
|
var cloneText = await cloneResp.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(cloneResp.IsSuccessStatusCode, cloneText);
|
||||||
|
var clone = JsonDocument.Parse(cloneText).RootElement.GetProperty("value").Clone();
|
||||||
|
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
||||||
|
Assert.False(clone.GetProperty("IsRRO").GetBoolean());
|
||||||
|
Assert.Equal(0, clone.GetProperty("Accessibility").GetInt32());
|
||||||
|
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true });
|
||||||
|
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = moderator.PlayerId, Role = RoomRoleRules.StoredModerator, Accepted = true });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish as the owner: full room details back, now public, still not RRO.
|
||||||
|
var published = await PutAccessibilityAsync(ownerRooms, cloneId, "1");
|
||||||
|
Assert.Equal(1, published.GetProperty("Accessibility").GetInt32());
|
||||||
|
Assert.False(published.GetProperty("IsRRO").GetBoolean());
|
||||||
|
Assert.True(published.TryGetProperty("SubRooms", out _));
|
||||||
|
|
||||||
|
// A moderator may not change accessibility; a co-owner may.
|
||||||
|
using var modRooms = Client("rooms", moderator);
|
||||||
|
using (var denied = await modRooms.PutAsync($"/roomserver/rooms/{cloneId}/accessibility",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = "0" })))
|
||||||
|
{
|
||||||
|
// Refusals are 200 {success:false, error} so the client can show them.
|
||||||
|
using var doc = JsonDocument.Parse(await denied.Content.ReadAsStringAsync());
|
||||||
|
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||||
|
}
|
||||||
|
using var coRooms = Client("rooms", coOwner);
|
||||||
|
var unpublished = await PutAccessibilityAsync(coRooms, cloneId, "0");
|
||||||
|
Assert.Equal(0, unpublished.GetProperty("Accessibility").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> PutAccessibilityAsync(HttpClient client, long roomId, string value)
|
||||||
|
{
|
||||||
|
using var response = await client.PutAsync($"/roomserver/rooms/{roomId}/accessibility",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = value }));
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {body}");
|
||||||
|
// "change room accessibility" goes through the {success, value, error}
|
||||||
|
// helper (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>), same as clone.
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), body);
|
||||||
|
return doc.RootElement.GetProperty("value").Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||||
|
{
|
||||||
|
using var response = await client.GetAsync(path);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||||
|
return JsonDocument.Parse(body).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
if (session is not null)
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2023 room roles, the co-owner invite flow and the sub-room role-permission
|
||||||
|
/// matrix, against the client contract:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Role enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30,
|
||||||
|
/// TemporaryCoOwner 31, Creator 255. The creator must be listed as 255 —
|
||||||
|
/// the client takes the local role from the Roles list and only lets a
|
||||||
|
/// player edit roles strictly below their own, Creator excepted
|
||||||
|
/// (OMJKHJLFOCO.DIIMLMKEJLC).</item>
|
||||||
|
/// <item>Invite: owner <c>PUT rooms/{id}/roles/{target}/invite</c> role=30;
|
||||||
|
/// invitee gets message Type 62 with RoomId; accepts with
|
||||||
|
/// <c>PUT rooms/{id}/roles/{me}</c> role=30, rejects with
|
||||||
|
/// <c>PUT rooms/{id}/roles/{me}/invite</c> role=0.</item>
|
||||||
|
/// <item>Permissions: <c>PUT rooms/{id}/subrooms/{sub}/permissions</c> raw
|
||||||
|
/// JSON array of {Permission, Role, Override, Type, Value}; read back from
|
||||||
|
/// <c>GET photon_access_token</c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public RoomRoles2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
// ── pure rules ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static RoomEntity Room(long creator) => new() { Id = 42, CreatorPlayerId = creator };
|
||||||
|
|
||||||
|
private static RoomRoleEntity Row(long player, int stored, bool accepted = true) =>
|
||||||
|
new() { RoomId = 42, PlayerId = player, Role = stored, Accepted = accepted };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Creator_is_255_and_listed_once()
|
||||||
|
{
|
||||||
|
var room = Room(creator: 7);
|
||||||
|
var wires = RoomRoleRules.BuildWires(room, new[]
|
||||||
|
{
|
||||||
|
Row(7, RoomRoleRules.StoredCoOwner), // a stray row for the creator
|
||||||
|
Row(8, RoomRoleRules.StoredModerator),
|
||||||
|
Row(9, RoomRoleRules.StoredCoOwner, accepted: false),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Single(wires, w => w.AccountId == 7);
|
||||||
|
Assert.Equal(255, wires.Single(w => w.AccountId == 7).Role);
|
||||||
|
Assert.Equal(20, wires.Single(w => w.AccountId == 8).Role);
|
||||||
|
var invited = wires.Single(w => w.AccountId == 9);
|
||||||
|
Assert.Equal(0, invited.Role);
|
||||||
|
Assert.Equal(30, invited.InvitedRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Creator_may_edit_every_role_including_co_owner_and_creator_permissions()
|
||||||
|
{
|
||||||
|
var room = Room(creator: 7);
|
||||||
|
var rows = Array.Empty<RoomRoleEntity>();
|
||||||
|
foreach (var role in new[] { 0, 10, 20, 30, 31, 255 })
|
||||||
|
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 7, role), $"creator role {role}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Co_owner_may_edit_only_roles_below_their_own()
|
||||||
|
{
|
||||||
|
var room = Room(creator: 7);
|
||||||
|
var rows = new[] { Row(8, RoomRoleRules.StoredCoOwner), Row(9, RoomRoleRules.StoredModerator) };
|
||||||
|
|
||||||
|
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 20));
|
||||||
|
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 0));
|
||||||
|
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 30));
|
||||||
|
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 31));
|
||||||
|
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 255));
|
||||||
|
// A moderator edits nothing.
|
||||||
|
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 9, 0));
|
||||||
|
// Nor does a stranger.
|
||||||
|
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 99, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Role_changes_follow_the_ladder()
|
||||||
|
{
|
||||||
|
var room = Room(creator: 7);
|
||||||
|
var rows = new List<RoomRoleEntity>
|
||||||
|
{
|
||||||
|
Row(8, RoomRoleRules.StoredCoOwner),
|
||||||
|
Row(9, RoomRoleRules.StoredModerator),
|
||||||
|
Row(10, RoomRoleRules.StoredCoOwner, accepted: false),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Creator: anything on anyone but themselves.
|
||||||
|
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 8, 0, false, out var k));
|
||||||
|
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
|
||||||
|
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 11, 30, true, out k));
|
||||||
|
Assert.Equal(RoomRoleRules.ChangeKind.Invite, k);
|
||||||
|
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 7, 7, 0, false, out _));
|
||||||
|
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 8, 7, 0, false, out _));
|
||||||
|
|
||||||
|
// Co-owner: only below 30, on targets below 30.
|
||||||
|
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 11, 20, false, out _));
|
||||||
|
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 9, 10, false, out _));
|
||||||
|
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 11, 30, true, out _));
|
||||||
|
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 10, 0, true, out _)); // invited co-owner
|
||||||
|
// …and cannot remove a peer co-owner.
|
||||||
|
Assert.Equal("forbidden", RoomRoleRules.CheckChange(
|
||||||
|
room, new List<RoomRoleEntity> { Row(8, 0), Row(12, 0) }, 8, 12, 0, false, out _));
|
||||||
|
|
||||||
|
// Moderator and strangers manage nobody.
|
||||||
|
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 11, 10, false, out _));
|
||||||
|
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 99, 11, 10, false, out _));
|
||||||
|
|
||||||
|
// Self-service: accept exactly the invited role, decline, leave.
|
||||||
|
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 30, false, out k));
|
||||||
|
Assert.Equal(RoomRoleRules.ChangeKind.AcceptInvite, k);
|
||||||
|
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 0, true, out k));
|
||||||
|
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
|
||||||
|
Assert.Null(RoomRoleRules.CheckChange(room, rows, 9, 9, 0, false, out _));
|
||||||
|
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 9, 30, false, out _)); // no invite
|
||||||
|
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 10, 10, 20, false, out _)); // not the invited role
|
||||||
|
|
||||||
|
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 255, false, out _));
|
||||||
|
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 31, false, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Permission_matrix_merges_stored_cells_over_defaults()
|
||||||
|
{
|
||||||
|
var merged = RoomPermissionMatrix.Merge(new[]
|
||||||
|
{
|
||||||
|
new RoomRolePermissionEntity { Id = 1, Role = 30, Permission = "13", Type = 0, Override = true, Value = "false" },
|
||||||
|
new RoomRolePermissionEntity { Id = 2, Role = 20, Permission = "13", Type = 0, Override = true, Value = "true" },
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("false", merged.Single(c => c.Role == 30 && c.Permission == "13").Value);
|
||||||
|
Assert.Equal("true", merged.Single(c => c.Role == 20 && c.Permission == "13").Value);
|
||||||
|
Assert.Equal("true", merged.Single(c => c.Role == 255 && c.Permission == "13").Value);
|
||||||
|
Assert.Equal(RoomPermissionMatrix.Defaults.Count + 1, merged.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Permission_body_accepts_numbers_strings_and_enum_names()
|
||||||
|
{
|
||||||
|
var cells = RoomPermissionMatrix.Parse(
|
||||||
|
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},""" +
|
||||||
|
"""{"permission":"CAN_USE_MAKER_PEN","role":"Moderator","override":"true","type":"Bool","value":true},""" +
|
||||||
|
"""{"Permission":"20","Role":"255","Override":false,"Type":1,"Value":5}]""");
|
||||||
|
|
||||||
|
Assert.NotNull(cells);
|
||||||
|
Assert.Equal(3, cells!.Count);
|
||||||
|
Assert.Equal(new RoomPermissionMatrix.Cell("13", 30, true, 0, "false"), cells[0]);
|
||||||
|
Assert.Equal(new RoomPermissionMatrix.Cell("CAN_USE_MAKER_PEN", 20, true, 0, "true"), cells[1]);
|
||||||
|
Assert.Equal(new RoomPermissionMatrix.Cell("20", 255, false, 1, "5"), cells[2]);
|
||||||
|
Assert.Null(RoomPermissionMatrix.Parse("role=30"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── over HTTP ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Roles_list_reports_the_creator_as_Creator_255()
|
||||||
|
{
|
||||||
|
var (owner, _, _) = await PlayersAsync();
|
||||||
|
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||||
|
|
||||||
|
using var rooms = Client("rooms", owner);
|
||||||
|
var list = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles");
|
||||||
|
var me = list.EnumerateArray().Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
|
||||||
|
Assert.Equal(255, me.GetProperty("Role").GetInt32());
|
||||||
|
|
||||||
|
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
|
||||||
|
var detailsRole = details.GetProperty("Roles").EnumerateArray()
|
||||||
|
.Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
|
||||||
|
Assert.Equal(255, detailsRole.GetProperty("Role").GetInt32());
|
||||||
|
|
||||||
|
var single = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles/{owner.PlayerId}");
|
||||||
|
Assert.Equal(255, single.GetProperty("Role").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Co_owner_invite_is_notified_then_accepted_by_the_invitee()
|
||||||
|
{
|
||||||
|
var (owner, invitee, _) = await PlayersAsync();
|
||||||
|
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||||
|
|
||||||
|
using var ownerRooms = Client("rooms", owner);
|
||||||
|
var invited = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||||
|
var inviteeRow = RoleOf(invited, invitee.PlayerId);
|
||||||
|
Assert.Equal(0, inviteeRow.GetProperty("Role").GetInt32());
|
||||||
|
Assert.Equal(30, inviteeRow.GetProperty("InvitedRole").GetInt32());
|
||||||
|
|
||||||
|
// The invitee's inbox carries message Type 62 (RoomCoOwnerInvited) with RoomId.
|
||||||
|
long messageId;
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var msg = await db.Messages.SingleAsync(m =>
|
||||||
|
m.RecipientPlayerId == invitee.PlayerId && m.RoomId == roomId
|
||||||
|
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited);
|
||||||
|
Assert.Equal(owner.PlayerId, msg.SenderPlayerId);
|
||||||
|
Assert.Null(msg.ReadAt);
|
||||||
|
messageId = msg.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept: the invitee PUTs their own role to 30.
|
||||||
|
using var inviteeRooms = Client("rooms", invitee);
|
||||||
|
var accepted = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
|
||||||
|
var acceptedRow = RoleOf(accepted, invitee.PlayerId);
|
||||||
|
Assert.Equal(30, acceptedRow.GetProperty("Role").GetInt32());
|
||||||
|
Assert.Equal(0, acceptedRow.GetProperty("InvitedRole").GetInt32());
|
||||||
|
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
Assert.NotNull((await db.Messages.SingleAsync(m => m.Id == messageId)).ReadAt);
|
||||||
|
Assert.True(await db.RoomRoles.AnyAsync(r =>
|
||||||
|
r.RoomId == roomId && r.PlayerId == invitee.PlayerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invitee_rejects_through_the_invite_route_and_cannot_self_promote()
|
||||||
|
{
|
||||||
|
var (owner, invitee, stranger) = await PlayersAsync();
|
||||||
|
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||||
|
|
||||||
|
using var ownerRooms = Client("rooms", owner);
|
||||||
|
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||||
|
|
||||||
|
using var inviteeRooms = Client("rooms", invitee);
|
||||||
|
var rejected = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "0"));
|
||||||
|
Assert.DoesNotContain(rejected.GetProperty("Roles").EnumerateArray(),
|
||||||
|
e => e.GetProperty("AccountId").GetInt64() == invitee.PlayerId);
|
||||||
|
|
||||||
|
// With the invite gone, the same accept is refused.
|
||||||
|
using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
|
||||||
|
await AssertRefusedAsync(again);
|
||||||
|
|
||||||
|
// A stranger can neither promote themselves nor invite anyone.
|
||||||
|
using var strangerRooms = Client("rooms", stranger);
|
||||||
|
using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20"));
|
||||||
|
await AssertRefusedAsync(self);
|
||||||
|
using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||||
|
await AssertRefusedAsync(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Co_owner_manages_roles_below_but_not_co_owners()
|
||||||
|
{
|
||||||
|
var (owner, coOwner, target) = await PlayersAsync();
|
||||||
|
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
|
||||||
|
|
||||||
|
using var coRooms = Client("rooms", coOwner);
|
||||||
|
var granted = await PutFormAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "20"));
|
||||||
|
Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32());
|
||||||
|
|
||||||
|
using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30"));
|
||||||
|
await AssertRefusedAsync(inviteCo);
|
||||||
|
|
||||||
|
// The creator can directly add and remove a co-owner.
|
||||||
|
using var ownerRooms = Client("rooms", owner);
|
||||||
|
var promoted = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "30"));
|
||||||
|
Assert.Equal(30, RoleOf(promoted, target.PlayerId).GetProperty("Role").GetInt32());
|
||||||
|
|
||||||
|
// …and now the co-owner can't touch their peer.
|
||||||
|
using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
|
||||||
|
await AssertRefusedAsync(demote);
|
||||||
|
|
||||||
|
var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
|
||||||
|
Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(),
|
||||||
|
e => e.GetProperty("AccountId").GetInt64() == target.PlayerId);
|
||||||
|
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
|
||||||
|
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerAdded));
|
||||||
|
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
|
||||||
|
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerRemoved));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Creator_saves_co_owner_and_creator_permissions_and_reads_them_back()
|
||||||
|
{
|
||||||
|
var (owner, coOwner, _) = await PlayersAsync();
|
||||||
|
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
|
||||||
|
|
||||||
|
using var ownerRooms = Client("rooms", owner);
|
||||||
|
var body = """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},{"Permission":"20","Role":255,"Override":true,"Type":0,"Value":"false"}]""";
|
||||||
|
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", body))
|
||||||
|
{
|
||||||
|
var text = await put.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}");
|
||||||
|
// {success, value: full room details, error}
|
||||||
|
var value = Envelope(text);
|
||||||
|
Assert.Equal(roomId, value.GetProperty("RoomId").GetInt64());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner enters the room, then the matrix comes back with the token.
|
||||||
|
using var match = Client("match", owner);
|
||||||
|
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
|
||||||
|
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
|
||||||
|
var token = await GetJsonAsync(ownerRooms, "/roomserver/photon_access_token");
|
||||||
|
var cells = token.GetProperty("Permissions").EnumerateArray().ToList();
|
||||||
|
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 30
|
||||||
|
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
|
||||||
|
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
|
||||||
|
&& c.GetProperty("Permission").GetString() == "20").GetProperty("Value").GetString());
|
||||||
|
Assert.Equal("true", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
|
||||||
|
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
|
||||||
|
|
||||||
|
// A co-owner may not edit the Co-Owner row, but may edit Moderator.
|
||||||
|
using var coRooms = Client("rooms", coOwner);
|
||||||
|
using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||||
|
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||||
|
await AssertRefusedAsync(denied);
|
||||||
|
using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||||
|
"""[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||||
|
Envelope(await allowed.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var stored = await db.RoomRolePermissions.Where(p => p.RoomId == roomId).ToListAsync();
|
||||||
|
Assert.Equal(3, stored.Count);
|
||||||
|
Assert.All(stored, p => Assert.Equal(0, p.SubRoomId)); // wire 1 → OrderIndex 0
|
||||||
|
Assert.Equal("false", stored.Single(p => p.Role == 30).Value); // the denied write changed nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invite_pushes_the_message_and_room_and_permission_saves_push_the_access_token()
|
||||||
|
{
|
||||||
|
var hub = new CapturingHubContext();
|
||||||
|
var factory = _factory.WithWebHostBuilder(builder =>
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions
|
||||||
|
.RemoveAll<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(services);
|
||||||
|
services.AddSingleton<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(hub);
|
||||||
|
}));
|
||||||
|
|
||||||
|
HttpClient FactoryClient(string sub, GameClientSession? session = null)
|
||||||
|
{
|
||||||
|
var c = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
c.BaseAddress = new Uri($"http://{sub}.{_factory.ApexDomain}");
|
||||||
|
c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
c.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
if (session is not null)
|
||||||
|
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var setup = FactoryClient("api");
|
||||||
|
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var invitee = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||||
|
|
||||||
|
using var ownerRooms = FactoryClient("rooms", owner);
|
||||||
|
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||||
|
|
||||||
|
var message = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "2"));
|
||||||
|
Assert.Equal(62, message.Msg.GetProperty("Type").GetInt32());
|
||||||
|
Assert.Equal(roomId, message.Msg.GetProperty("RoomId").GetInt64());
|
||||||
|
Assert.Equal((int)owner.PlayerId, message.Msg.GetProperty("FromPlayerId").GetInt32());
|
||||||
|
var roomPush = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "RoomUpdate"));
|
||||||
|
Assert.Equal(30, RoleOf(roomPush.Msg, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
|
||||||
|
|
||||||
|
using var match = FactoryClient("match", owner);
|
||||||
|
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
|
||||||
|
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
|
||||||
|
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||||
|
"""[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||||
|
Envelope(await put.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken"));
|
||||||
|
Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0);
|
||||||
|
Assert.Contains(token.Msg.GetProperty("Permissions").EnumerateArray(),
|
||||||
|
c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Envelope_filter_wraps_success_and_turns_refusals_into_messages()
|
||||||
|
{
|
||||||
|
static JsonElement Body(Microsoft.AspNetCore.Mvc.IActionResult r) =>
|
||||||
|
JsonDocument.Parse(JsonSerializer.Serialize(((Microsoft.AspNetCore.Mvc.ObjectResult)r).Value)).RootElement.Clone();
|
||||||
|
|
||||||
|
var ok = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.OkObjectResult(new { RoomId = 5 })));
|
||||||
|
Assert.True(ok.GetProperty("success").GetBoolean());
|
||||||
|
Assert.Equal(5, ok.GetProperty("value").GetProperty("RoomId").GetInt32());
|
||||||
|
Assert.Equal("", ok.GetProperty("error").GetString());
|
||||||
|
|
||||||
|
var forbid = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.ForbidResult()));
|
||||||
|
Assert.False(forbid.GetProperty("success").GetBoolean());
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(forbid.GetProperty("error").GetString()));
|
||||||
|
|
||||||
|
var bad = Body(Controllers.RecNetEnvelopeAttribute.Wrap(
|
||||||
|
new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(new { error = "cannot_change_creator_role" })));
|
||||||
|
Assert.Equal("cannot_change_creator_role", bad.GetProperty("error").GetString());
|
||||||
|
|
||||||
|
// An envelope the handler built itself is left alone.
|
||||||
|
var already = new Microsoft.AspNetCore.Mvc.OkObjectResult(new { success = false, value = (object?)null, error = "x" });
|
||||||
|
Assert.Same(already, Controllers.RecNetEnvelopeAttribute.Wrap(already));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Room_mutations_answer_the_success_value_error_envelope_on_bare_and_roomserver_paths()
|
||||||
|
{
|
||||||
|
var (owner, invitee, _) = await PlayersAsync();
|
||||||
|
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||||
|
using var rooms = Client("rooms", owner);
|
||||||
|
|
||||||
|
// The exact request the client sent in production (bare path, form role=30).
|
||||||
|
var invited = await PutFormAsync(rooms, $"/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||||
|
Assert.Equal(30, RoleOf(invited, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
|
||||||
|
|
||||||
|
foreach (var (path, key, value) in new[]
|
||||||
|
{
|
||||||
|
($"/rooms/{roomId}/description", "description", "hello"),
|
||||||
|
($"/roomserver/rooms/{roomId}/accessibility", "accessibility", "0"),
|
||||||
|
($"/roomserver/rooms/{roomId}/subrooms/1/name", "name", "Lobby"),
|
||||||
|
($"/roomserver/rooms/{roomId}/subrooms/1/maxplayers", "maxPlayers", "10"),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var details = await PutFormAsync(rooms, path, (key, value));
|
||||||
|
Assert.Equal(roomId, details.GetProperty("RoomId").GetInt64());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync()
|
||||||
|
{
|
||||||
|
using var setup = Client("api");
|
||||||
|
return (
|
||||||
|
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
|
||||||
|
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
|
||||||
|
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<long> SeedRoomAsync(long creatorId, params (long PlayerId, int StoredRole)[] roles)
|
||||||
|
{
|
||||||
|
var roomId = 9_300_000 + Random.Shared.Next(1, 99_999);
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
db.Rooms.Add(new RoomEntity
|
||||||
|
{
|
||||||
|
Id = roomId,
|
||||||
|
Name = $"Roles{Guid.NewGuid():N}"[..18],
|
||||||
|
CreatorPlayerId = creatorId,
|
||||||
|
ImageName = RoomService.DefaultRoomImageName,
|
||||||
|
Accessibility = 0,
|
||||||
|
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||||
|
});
|
||||||
|
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = 0, Name = "Home" });
|
||||||
|
foreach (var (pid, role) in roles)
|
||||||
|
db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = pid, Role = role, Accepted = true });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return roomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement RoleOf(JsonElement details, long playerId) =>
|
||||||
|
details.GetProperty("Roles").EnumerateArray()
|
||||||
|
.Single(e => e.GetProperty("AccountId").GetInt64() == playerId);
|
||||||
|
|
||||||
|
private static Task<HttpResponseMessage> PutFormRawAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||||
|
=> client.PutAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
|
||||||
|
|
||||||
|
private static async Task<JsonElement> PutFormAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||||
|
{
|
||||||
|
using var response = await PutFormRawAsync(client, path, fields);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}");
|
||||||
|
return Envelope(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every room mutation answers the {success, value, error}
|
||||||
|
/// envelope (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>): assert success and
|
||||||
|
/// return the room details in <c>value</c>.</summary>
|
||||||
|
private static JsonElement Envelope(string body)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.GetProperty("success").GetBoolean(), body);
|
||||||
|
Assert.Equal(string.Empty, root.GetProperty("error").GetString());
|
||||||
|
return root.GetProperty("value").Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A refusal is HTTP 200 with success=false and a message: the
|
||||||
|
/// client shows the message, while a 403/404 surfaces only as the
|
||||||
|
/// message-less "Exception of type 'CEMNLBKJABA' was thrown".</summary>
|
||||||
|
private static async Task AssertRefusedAsync(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
Assert.False(doc.RootElement.GetProperty("success").GetBoolean(), body);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(doc.RootElement.GetProperty("error").GetString()), body);
|
||||||
|
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("value").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<HttpResponseMessage> PutJsonRawAsync(HttpClient client, string path, string json)
|
||||||
|
=> client.PutAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
|
||||||
|
|
||||||
|
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||||
|
{
|
||||||
|
using var response = await client.GetAsync(path);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||||
|
return JsonDocument.Parse(body).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
if (session is not null)
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -267,8 +267,10 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
|||||||
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
||||||
|
|
||||||
using var json = JsonDocument.Parse(text);
|
using var json = JsonDocument.Parse(text);
|
||||||
var root = json.RootElement;
|
// {success, value, error} envelope around the full room-details
|
||||||
// Full room-details shape (FGCPNAACHIK), not a status wrapper.
|
// shape (FGCPNAACHIK).
|
||||||
|
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), text);
|
||||||
|
var root = json.RootElement.GetProperty("value");
|
||||||
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
|
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
|
||||||
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
|
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
|
||||||
return root.GetProperty("RoomId").GetInt64();
|
return root.GetProperty("RoomId").GetInt64();
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
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 RecRoom.Protobuf;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baked Rec Room Originals (Rec Royale, Paintball, …) ship with an empty
|
||||||
|
/// <see cref="RoomSceneEntity.DataBlobName"/>, and every details builder and
|
||||||
|
/// matchmaking resolver turns that into an empty <c>DataBlob</c>. The client
|
||||||
|
/// reads that as "no room data at all": no persistence views, no room-role
|
||||||
|
/// overlay, no Maker Pen, no CV2 chips, no room mood engine. Real Rec Room
|
||||||
|
/// served these rooms a blob — Rec Royale's game-rule chip lives in it, not
|
||||||
|
/// in the scene — so DorkNet needs a switch that gives such a room one.
|
||||||
|
///
|
||||||
|
/// The property pinned here: the admin "enable blob" action stamps the
|
||||||
|
/// synthetic default name, that name flows through the game-client details
|
||||||
|
/// verbatim while <c>IsRRO</c> stays true (the client still loads the baked
|
||||||
|
/// map, and only additionally fetches the blob), the CDN answers that name
|
||||||
|
/// with the RRO-editable blob (permissive room roles, no objects), and
|
||||||
|
/// "reset" puts everything back to scene-only.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public RroRoomBlobTests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Baked_original_gets_no_blob_until_an_admin_enables_one()
|
||||||
|
{
|
||||||
|
using var setup = Client("rooms");
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
|
||||||
|
var roomName = $"Royale{Guid.NewGuid():N}"[..18];
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||||
|
|
||||||
|
// Shape of a seeded baked original: system-owned, AG-flagged,
|
||||||
|
// tagged recroomoriginal, no blob anywhere.
|
||||||
|
db.Rooms.Add(new RoomEntity
|
||||||
|
{
|
||||||
|
Id = roomId,
|
||||||
|
Name = roomName,
|
||||||
|
CreatorPlayerId = 1,
|
||||||
|
IsAGRoom = true,
|
||||||
|
TagsCsv = "recroomoriginal,sport",
|
||||||
|
CurrentDataBlobName = string.Empty,
|
||||||
|
LocationReplicationId = "b010171f-4875-4e89-baba-61e878cd41e1",
|
||||||
|
});
|
||||||
|
db.RoomScenes.Add(new RoomSceneEntity
|
||||||
|
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = string.Empty });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
||||||
|
using var gameClient = Client("rooms", player);
|
||||||
|
using var adminClient = Client("admin", admin);
|
||||||
|
using var cdnClient = Client("cdn");
|
||||||
|
|
||||||
|
// ── before: baked scene only ────────────────────────────────────────
|
||||||
|
var before = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||||
|
Assert.True(before.GetProperty("IsRRO").GetBoolean());
|
||||||
|
Assert.Equal(string.Empty, SubRoomZero(before).GetProperty("DataBlob").GetString());
|
||||||
|
|
||||||
|
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||||
|
var row = listed.EnumerateArray().Single();
|
||||||
|
Assert.True(row.GetProperty("isBakedOriginal").GetBoolean());
|
||||||
|
Assert.Equal(string.Empty, row.GetProperty("effectiveBlobName").GetString());
|
||||||
|
|
||||||
|
// ── enable: the synthetic name lands on the scene AND the room ─────
|
||||||
|
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||||
|
Assert.True(enabled.GetProperty("changed").GetBoolean());
|
||||||
|
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
|
||||||
|
Assert.Equal(synthetic, enabled.GetProperty("dataBlobName").GetString());
|
||||||
|
Assert.Equal(synthetic, enabled.GetProperty("roomCurrentDataBlobName").GetString());
|
||||||
|
|
||||||
|
// Idempotent: a second enable changes nothing and keeps the name.
|
||||||
|
var again = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||||
|
Assert.False(again.GetProperty("changed").GetBoolean());
|
||||||
|
Assert.Equal(synthetic, again.GetProperty("effectiveBlobName").GetString());
|
||||||
|
|
||||||
|
// The game client now gets the blob name, and IsRRO is untouched —
|
||||||
|
// that flag is what makes it load the baked map (a false there sends
|
||||||
|
// it looking for geometry inside the blob and the join fails).
|
||||||
|
var after = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||||
|
Assert.True(after.GetProperty("IsRRO").GetBoolean());
|
||||||
|
Assert.Equal(synthetic, SubRoomZero(after).GetProperty("DataBlob").GetString());
|
||||||
|
|
||||||
|
// ── the CDN answers that name with the RRO-editable blob ───────────
|
||||||
|
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
|
||||||
|
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
|
||||||
|
Assert.True(blobResponse.IsSuccessStatusCode,
|
||||||
|
$"GET cdn /room/{synthetic} -> {(int)blobResponse.StatusCode}");
|
||||||
|
var persisted = PersistedRoomData.Parser.ParseFrom(blobBytes);
|
||||||
|
Assert.NotNull(persisted.RoomRoleData);
|
||||||
|
Assert.NotEmpty(persisted.RoomRoleData.RoomRoles);
|
||||||
|
Assert.True(persisted.RoomRoleData.RoomRoles.All(r => r.CanUseMakerPen is { Overrides: true, InnerValue: true }));
|
||||||
|
|
||||||
|
// The March-2023 client rejects a blob past PersistedRoomVersion 16
|
||||||
|
// (the old stub was stamped 19 and loaded as "no room data"), and a
|
||||||
|
// real save carries the room's location id as activity_id. Our proto
|
||||||
|
// doesn't type activity_id, so look for it on the wire.
|
||||||
|
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)persisted.Version);
|
||||||
|
var blobText = System.Text.Encoding.UTF8.GetString(blobBytes);
|
||||||
|
Assert.Contains("b010171f-4875-4e89-baba-61e878cd41e1", blobText);
|
||||||
|
|
||||||
|
// Every real room save carries three GameRoleNode chips (default role
|
||||||
|
// named after the room, "In-Game", "Eliminated" — well-known GUIDs).
|
||||||
|
// The 2023 client grants Maker Pen off the player's GameRole, so a
|
||||||
|
// blob with no role objects leaves even a co-owner with nothing.
|
||||||
|
Assert.Equal(3, CountTopLevelPersistenceViews(blobBytes));
|
||||||
|
Assert.Contains("In-Game", blobText);
|
||||||
|
Assert.Contains("Eliminated", blobText);
|
||||||
|
Assert.Contains("2c721342-c7ed-49fe-b8f0-93f8d282a6df", blobText);
|
||||||
|
Assert.Contains("0b9ec39e-2cc9-4825-8321-da11561f1c4f", blobText);
|
||||||
|
Assert.Contains("c48bac02-a743-4797-94c4-e2d6f0749004", blobText);
|
||||||
|
Assert.Contains(roomName, blobText);
|
||||||
|
|
||||||
|
// ── reset: back to scene-only ───────────────────────────────────────
|
||||||
|
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
|
||||||
|
Assert.True(reset.GetProperty("changed").GetBoolean());
|
||||||
|
Assert.Equal(string.Empty, reset.GetProperty("effectiveBlobName").GetString());
|
||||||
|
|
||||||
|
var restored = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||||
|
Assert.True(restored.GetProperty("IsRRO").GetBoolean());
|
||||||
|
Assert.Equal(string.Empty, SubRoomZero(restored).GetProperty("DataBlob").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The seeded Rec Royale rows have NO RoomScenes at all (the admin
|
||||||
|
/// page showed "Scenes 0 / No sub-rooms"). The admin list must synthesise
|
||||||
|
/// the entry scene the way the game-client details do, and enable must
|
||||||
|
/// create the row rather than 404.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Room_without_scene_rows_gets_a_virtual_entry_scene_and_enable_creates_it()
|
||||||
|
{
|
||||||
|
using var setup = Client("rooms");
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
var roomId = 9_670_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 = $"NoScene{Guid.NewGuid():N}"[..18],
|
||||||
|
CreatorPlayerId = 1,
|
||||||
|
IsAGRoom = true,
|
||||||
|
TagsCsv = "recroomoriginal",
|
||||||
|
MaxCapacity = 24,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
||||||
|
using var gameClient = Client("rooms", player);
|
||||||
|
using var adminClient = Client("admin", admin);
|
||||||
|
|
||||||
|
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||||
|
var virtualRow = listed.EnumerateArray().Single();
|
||||||
|
Assert.True(virtualRow.GetProperty("isVirtual").GetBoolean());
|
||||||
|
Assert.Equal(0, virtualRow.GetProperty("subRoomId").GetInt32());
|
||||||
|
Assert.Equal(24, virtualRow.GetProperty("maxPlayers").GetInt32());
|
||||||
|
Assert.Equal(string.Empty, virtualRow.GetProperty("effectiveBlobName").GetString());
|
||||||
|
|
||||||
|
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||||
|
Assert.True(enabled.GetProperty("changed").GetBoolean());
|
||||||
|
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
|
||||||
|
|
||||||
|
// The row now exists with the game-side defaults, and the game client
|
||||||
|
// sees the blob on its entry scene.
|
||||||
|
var relisted = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||||
|
var real = relisted.EnumerateArray().Single();
|
||||||
|
Assert.False(real.TryGetProperty("isVirtual", out _));
|
||||||
|
Assert.Equal("Home", real.GetProperty("name").GetString());
|
||||||
|
Assert.Equal(24, real.GetProperty("maxPlayers").GetInt32());
|
||||||
|
Assert.Equal(synthetic, real.GetProperty("dataBlobName").GetString());
|
||||||
|
|
||||||
|
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||||
|
Assert.True(details.GetProperty("IsRRO").GetBoolean());
|
||||||
|
Assert.Equal(synthetic, SubRoomZero(details).GetProperty("DataBlob").GetString());
|
||||||
|
|
||||||
|
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
|
||||||
|
Assert.True(reset.GetProperty("changed").GetBoolean());
|
||||||
|
Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rec Royale's Frontier scene has no baked game rule; real Rec
|
||||||
|
/// Room delivered the RecRoyaleSolos/Squads_GameRuleWrapperCircuit chip
|
||||||
|
/// in the blob. The stub for those rooms must carry it (a fourth
|
||||||
|
/// persistence view with the palette tag the client generates).</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("RecRoyaleSolos", "recroyalesolosgamerulecir")]
|
||||||
|
[InlineData("RecRoyaleSquads", "recroyalesquadsgameruleci")]
|
||||||
|
public async Task Rec_royale_stub_carries_its_game_rule_chip(string roomNamePrefix, string expectedTag)
|
||||||
|
{
|
||||||
|
using var setup = Client("rooms");
|
||||||
|
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
var roomId = 9_680_000 + Random.Shared.Next(1, 99_999);
|
||||||
|
var roomName = $"{roomNamePrefix}-{Guid.NewGuid():N}"[..24];
|
||||||
|
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 = roomName,
|
||||||
|
CreatorPlayerId = 1,
|
||||||
|
IsAGRoom = true,
|
||||||
|
TagsCsv = "recroomoriginal",
|
||||||
|
LocationReplicationId = "253fa009-6e65-4c90-91a1-7137a56a267f",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var adminClient = Client("admin", admin);
|
||||||
|
using var cdnClient = Client("cdn");
|
||||||
|
await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||||
|
|
||||||
|
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
||||||
|
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
|
||||||
|
Assert.True(blobResponse.IsSuccessStatusCode);
|
||||||
|
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
|
||||||
|
Assert.Equal(4, CountTopLevelPersistenceViews(blobBytes)); // 3 roles + the rule chip
|
||||||
|
Assert.Contains(expectedTag, System.Text.Encoding.UTF8.GetString(blobBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A real save from any client version can be attached to a baked
|
||||||
|
/// original: activity id rewritten to the target room, output log /
|
||||||
|
/// sub-room id / unknown fields dropped, version clamped, and in
|
||||||
|
/// <c>basics</c> mode only the role + rule objects kept with circuits
|
||||||
|
/// stripped. The room then loads that blob (with the CDN's role overlay).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Uploading_a_real_save_attaches_it_to_the_room_rewritten_for_2023()
|
||||||
|
{
|
||||||
|
using var setup = Client("rooms");
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
var roomId = 9_690_000 + Random.Shared.Next(1, 99_999);
|
||||||
|
const string location = "253fa009-6e65-4c90-91a1-7137a56a267f";
|
||||||
|
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 = $"Upload{Guid.NewGuid():N}"[..18],
|
||||||
|
CreatorPlayerId = 1,
|
||||||
|
IsAGRoom = true,
|
||||||
|
TagsCsv = "recroomoriginal",
|
||||||
|
LocationReplicationId = location,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "modern" save: version 138, someone else's activity id, an output
|
||||||
|
// log, a sub-room id, a newer-only field 37, circuit payloads, and
|
||||||
|
// three views — a game role (field 50), a game rule (field 41) and a
|
||||||
|
// plain prop (field 15 only).
|
||||||
|
var modern = BuildModernSave();
|
||||||
|
|
||||||
|
using var adminClient = Client("admin", admin);
|
||||||
|
using var form = new MultipartFormDataContent();
|
||||||
|
form.Add(new ByteArrayContent(modern), "file", "persisted_room_data.binpb");
|
||||||
|
using var uploaded = await adminClient.PostAsync($"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/upload?mode=basics", form);
|
||||||
|
var uploadBody = await uploaded.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(uploaded.IsSuccessStatusCode, $"upload -> {(int)uploaded.StatusCode}: {uploadBody}");
|
||||||
|
var result = JsonDocument.Parse(uploadBody).RootElement;
|
||||||
|
var blobName = result.GetProperty("dataBlobName").GetString()!;
|
||||||
|
Assert.StartsWith($"room_{roomId}_upload_", blobName);
|
||||||
|
var summary = result.GetProperty("summary");
|
||||||
|
Assert.Equal(3, summary.GetProperty("viewsIn").GetInt32());
|
||||||
|
Assert.Equal(2, summary.GetProperty("viewsKept").GetInt32());
|
||||||
|
Assert.Equal(138, summary.GetProperty("versionIn").GetInt32());
|
||||||
|
Assert.Equal(16, summary.GetProperty("versionOut").GetInt32());
|
||||||
|
|
||||||
|
// The game client is now pointed at the uploaded blob …
|
||||||
|
using var gameClient = Client("rooms", player);
|
||||||
|
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||||
|
Assert.Equal(blobName, SubRoomZero(details).GetProperty("DataBlob").GetString());
|
||||||
|
|
||||||
|
// … and the CDN serves it rewritten: 2 views, clamped header, our
|
||||||
|
// activity id, the role overlay applied, no field 37 / output log.
|
||||||
|
using var cdnClient = Client("cdn");
|
||||||
|
using var served = await cdnClient.GetAsync($"/room/{blobName}");
|
||||||
|
Assert.True(served.IsSuccessStatusCode, $"cdn -> {(int)served.StatusCode}");
|
||||||
|
var bytes = await served.Content.ReadAsByteArrayAsync();
|
||||||
|
var parsed = PersistedRoomData.Parser.ParseFrom(bytes);
|
||||||
|
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)parsed.Version);
|
||||||
|
Assert.NotEmpty(parsed.RoomRoleData.RoomRoles);
|
||||||
|
Assert.Equal(2, CountTopLevelPersistenceViews(bytes));
|
||||||
|
var text = System.Text.Encoding.UTF8.GetString(bytes);
|
||||||
|
Assert.Contains(location, text);
|
||||||
|
Assert.DoesNotContain("someone-elses-activity", text);
|
||||||
|
Assert.DoesNotContain("Serialized 3 persistence views", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BuildModernSave()
|
||||||
|
{
|
||||||
|
var ms = new MemoryStream();
|
||||||
|
void Varint(int field, ulong v) { WriteVarint(ms, ((ulong)field << 3) | 0); WriteVarint(ms, v); }
|
||||||
|
void Bytes(int field, byte[] payload) { WriteVarint(ms, ((ulong)field << 3) | 2); WriteVarint(ms, (ulong)payload.Length); ms.Write(payload); }
|
||||||
|
static byte[] View(int markerField)
|
||||||
|
{
|
||||||
|
var v = new MemoryStream();
|
||||||
|
WriteVarint(v, (1UL << 3) | 2); WriteVarint(v, 16); v.Write(Guid.NewGuid().ToByteArray());
|
||||||
|
WriteVarint(v, ((ulong)markerField << 3) | 2); WriteVarint(v, 0);
|
||||||
|
return v.ToArray();
|
||||||
|
}
|
||||||
|
Varint(1, 38);
|
||||||
|
Bytes(2, View(50)); // game role
|
||||||
|
Bytes(2, View(41)); // game rule wrapper
|
||||||
|
Bytes(2, View(15)); // a prop (tagged tool only)
|
||||||
|
Bytes(4, System.Text.Encoding.UTF8.GetBytes("someone-elses-activity-0000-000000000000"));
|
||||||
|
Bytes(5, System.Text.Encoding.UTF8.GetBytes("Serialized 3 persistence views"));
|
||||||
|
Varint(6, 3991471883933115623UL);
|
||||||
|
Bytes(18, new byte[] { 0x0A, 0x02, 0x08, 0x01 });
|
||||||
|
Bytes(28, new byte[] { 0x0A, 0x02, 0x08, 0x01 });
|
||||||
|
Varint(30, 138);
|
||||||
|
Varint(31, 1);
|
||||||
|
Varint(37, 1);
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteVarint(Stream s, ulong value)
|
||||||
|
{
|
||||||
|
while (value >= 0x80) { s.WriteByte((byte)(value | 0x80)); value >>= 7; }
|
||||||
|
s.WriteByte((byte)value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob()
|
||||||
|
{
|
||||||
|
using var setup = Client("rooms");
|
||||||
|
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
var roomId = 9_650_000 + Random.Shared.Next(1, 99_999);
|
||||||
|
const string saved = "room_saved_by_a_player_v3.dat";
|
||||||
|
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 = $"Saved{Guid.NewGuid():N}"[..18],
|
||||||
|
CreatorPlayerId = 1,
|
||||||
|
IsAGRoom = true,
|
||||||
|
TagsCsv = "recroomoriginal",
|
||||||
|
CurrentDataBlobName = saved,
|
||||||
|
});
|
||||||
|
db.RoomScenes.Add(new RoomSceneEntity
|
||||||
|
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = saved });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var adminClient = Client("admin", admin);
|
||||||
|
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||||
|
Assert.False(enabled.GetProperty("changed").GetBoolean());
|
||||||
|
Assert.Equal(saved, enabled.GetProperty("effectiveBlobName").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Count top-level <c>persistence_views</c> (field 2) entries by
|
||||||
|
/// walking the wire format — our server proto doesn't type field 2.</summary>
|
||||||
|
private static int CountTopLevelPersistenceViews(byte[] blob)
|
||||||
|
{
|
||||||
|
var pos = 0;
|
||||||
|
var count = 0;
|
||||||
|
while (pos < blob.Length)
|
||||||
|
{
|
||||||
|
var tag = ReadVarint(blob, ref pos);
|
||||||
|
var field = (int)(tag >> 3);
|
||||||
|
var wireType = (int)(tag & 7);
|
||||||
|
switch (wireType)
|
||||||
|
{
|
||||||
|
case 0: ReadVarint(blob, ref pos); break;
|
||||||
|
case 1: pos += 8; break;
|
||||||
|
case 2:
|
||||||
|
var len = (int)ReadVarint(blob, ref pos);
|
||||||
|
if (field == 2) count++;
|
||||||
|
pos += len;
|
||||||
|
break;
|
||||||
|
case 5: pos += 4; break;
|
||||||
|
default: throw new InvalidDataException($"wire type {wireType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong ReadVarint(byte[] input, ref int pos)
|
||||||
|
{
|
||||||
|
ulong value = 0;
|
||||||
|
var shift = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var b = input[pos++];
|
||||||
|
value |= (ulong)(b & 0x7F) << shift;
|
||||||
|
if ((b & 0x80) == 0) return value;
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The entry sub-room (OrderIndex 0) is wire SubRoomId 1 (RoomSceneIds).
|
||||||
|
private static JsonElement SubRoomZero(JsonElement details) =>
|
||||||
|
details.GetProperty("SubRooms").EnumerateArray()
|
||||||
|
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
|
||||||
|
|
||||||
|
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 static async Task<JsonElement> PostJsonAsync(HttpClient client, string path)
|
||||||
|
{
|
||||||
|
using var response = await client.PostAsync(path, content: null);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"POST {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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── in-game: the owner's slider on sub-room 1 only ──────────────────
|
// ── in-game: the owner's slider on the second sub-room only ─────────
|
||||||
|
// The client addresses it by its wire SubRoomId, OrderIndex + 1 = 2
|
||||||
|
// (RoomSceneIds — the client will not load a sub-room whose id is 0).
|
||||||
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
|
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
|
||||||
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
|
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
|
||||||
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
|
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
|
||||||
@@ -66,19 +68,23 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
|||||||
// real client's body went unread.
|
// real client's body went unread.
|
||||||
using var ownerClient = Client("rooms", owner);
|
using var ownerClient = Client("rooms", owner);
|
||||||
using var slider = await ownerClient.PutAsJsonAsync(
|
using var slider = await ownerClient.PutAsJsonAsync(
|
||||||
$"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
|
$"/rooms/{roomId}/subrooms/2/maxplayers", new { maxPlayers = 24 });
|
||||||
var sliderBody = await slider.Content.ReadAsStringAsync();
|
var sliderBody = await slider.Content.ReadAsStringAsync();
|
||||||
Assert.True(slider.IsSuccessStatusCode,
|
Assert.True(slider.IsSuccessStatusCode,
|
||||||
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
|
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
|
||||||
|
|
||||||
// The client deserialises the reply as FGCPNAACHIK — the room-details
|
// The client reads the reply through the {success, value, error}
|
||||||
// shape — so the slider's own response has to carry the new value.
|
// helper with value = FGCPNAACHIK, so the slider's own response has to
|
||||||
var echoed = JsonDocument.Parse(sliderBody).RootElement
|
// carry the new value inside the envelope.
|
||||||
|
var envelope = JsonDocument.Parse(sliderBody).RootElement;
|
||||||
|
Assert.True(envelope.GetProperty("success").GetBoolean(), sliderBody);
|
||||||
|
var echoed = envelope.GetProperty("value")
|
||||||
.GetProperty("SubRooms").EnumerateArray()
|
.GetProperty("SubRooms").EnumerateArray()
|
||||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
|
.First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
|
||||||
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
|
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
|
||||||
|
|
||||||
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
|
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
|
||||||
|
// (the admin API addresses sub-rooms by OrderIndex)
|
||||||
using var adminClient = Client("admin", admin);
|
using var adminClient = Client("admin", admin);
|
||||||
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||||
var subRooms = listed.EnumerateArray()
|
var subRooms = listed.EnumerateArray()
|
||||||
@@ -99,8 +105,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
|||||||
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
|
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
|
||||||
e => e.GetProperty("MaxPlayers").GetInt32());
|
e => e.GetProperty("MaxPlayers").GetInt32());
|
||||||
|
|
||||||
Assert.Equal(40, wire[0]);
|
// Wire ids are OrderIndex + 1.
|
||||||
Assert.Equal(24, wire[1]);
|
Assert.Equal(40, wire[1]);
|
||||||
|
Assert.Equal(24, wire[2]);
|
||||||
|
|
||||||
// The room-level figure is the room's advertised capacity, NOT a copy
|
// 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.
|
// of sub-room 0 — otherwise the admin edit above would have moved it.
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sub-room ids and the room instance browser for the 2023 client.
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>The client will not load a sub-room whose id is not positive
|
||||||
|
/// (SubroomModel.DOKCPLJOKIE: <c>RoomId > 0 && SubroomId > 0</c>),
|
||||||
|
/// so the wire SubRoomId is OrderIndex + 1 everywhere the client sees it:
|
||||||
|
/// room details, sub-room routes, matchmaking and the instance browser.</item>
|
||||||
|
/// <item>A new room always owns a real, named entry sub-room, and a startup
|
||||||
|
/// pass repairs rooms that lack one.</item>
|
||||||
|
/// <item><c>GET room/{id}/instances</c> lists live instances with their real
|
||||||
|
/// players, including the private instance of a private room for the people
|
||||||
|
/// allowed into it.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SubRoomsAndInstances2023Tests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public SubRoomsAndInstances2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Wire_sub_room_ids_are_positive_and_round_trip()
|
||||||
|
{
|
||||||
|
Assert.Equal(1, RoomSceneIds.ToWire(0));
|
||||||
|
Assert.Equal(3, RoomSceneIds.ToWire(2));
|
||||||
|
Assert.Equal(0, RoomSceneIds.ToOrderIndex(1));
|
||||||
|
Assert.Equal(2, RoomSceneIds.ToOrderIndex(3));
|
||||||
|
// 0 (cached client state, bare matchmake) is the entry sub-room.
|
||||||
|
Assert.Equal(0, RoomSceneIds.ToOrderIndex(0));
|
||||||
|
Assert.Equal(0, RoomSceneIds.ToOrderIndex(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Details_matchmaking_and_sub_room_routes_share_the_wire_id()
|
||||||
|
{
|
||||||
|
var owner = await PlayerAsync();
|
||||||
|
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 1, scenes: new[] { "Home", "Arena" });
|
||||||
|
|
||||||
|
using var rooms = Client("rooms", owner);
|
||||||
|
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
|
||||||
|
var subRooms = details.GetProperty("SubRooms").EnumerateArray().ToList();
|
||||||
|
Assert.Equal(new long[] { 1, 2 }, subRooms.Select(s => s.GetProperty("SubRoomId").GetInt64()));
|
||||||
|
Assert.Equal("Home", subRooms[0].GetProperty("Name").GetString());
|
||||||
|
|
||||||
|
// matchmake/room/{id}/{sub} takes the wire id and echoes it.
|
||||||
|
using var match = Client("match", owner);
|
||||||
|
var arena = await PostFormAsync(match, $"/matchmake/room/{roomId}/2");
|
||||||
|
Assert.Equal(2, arena.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
|
||||||
|
var lobby = await PostFormAsync(match, $"/matchmake/room/{roomId}");
|
||||||
|
Assert.Equal(1, lobby.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
|
||||||
|
|
||||||
|
// A sub-room route addressed by wire id 2 edits OrderIndex 1.
|
||||||
|
using (var rename = await rooms.PutAsync($"/roomserver/rooms/{roomId}/subrooms/2/name",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = "Basement" })))
|
||||||
|
Assert.True(rename.IsSuccessStatusCode, await rename.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
Assert.Equal("Basement", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 1)).Name);
|
||||||
|
Assert.Equal("Home", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 0)).Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cloning_a_template_without_scene_rows_creates_a_named_home_sub_room()
|
||||||
|
{
|
||||||
|
var owner = await PlayerAsync();
|
||||||
|
var templateId = await SeedRoomAsync(1, accessibility: 1, scenes: Array.Empty<string>(), isAg: true);
|
||||||
|
|
||||||
|
using var rooms = Client("rooms", owner);
|
||||||
|
using var resp = await rooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Fresh{Guid.NewGuid():N}"[..20] }));
|
||||||
|
var text = await resp.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(resp.IsSuccessStatusCode, text);
|
||||||
|
using var json = JsonDocument.Parse(text);
|
||||||
|
var value = json.RootElement.GetProperty("value");
|
||||||
|
var cloneId = value.GetProperty("RoomId").GetInt64();
|
||||||
|
var sub = value.GetProperty("SubRooms").EnumerateArray().Single();
|
||||||
|
Assert.Equal(1, sub.GetProperty("SubRoomId").GetInt64());
|
||||||
|
Assert.Equal("Home", sub.GetProperty("Name").GetString());
|
||||||
|
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == cloneId);
|
||||||
|
Assert.Equal(0, scene.OrderIndex);
|
||||||
|
Assert.Equal("Home", scene.Name);
|
||||||
|
Assert.Equal("a75f7547-79eb-47c6-8986-6767abcb4f92", scene.RoomSceneLocationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Startup_repair_gives_scene_less_rooms_a_home_and_names_blank_rows()
|
||||||
|
{
|
||||||
|
var bare = await SeedRoomAsync(1, accessibility: 0, scenes: Array.Empty<string>());
|
||||||
|
var blank = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "", " " });
|
||||||
|
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||||
|
var (added, renamed) = await service.RepairRoomScenesAsync();
|
||||||
|
Assert.True(added >= 1);
|
||||||
|
Assert.True(renamed >= 2);
|
||||||
|
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var home = await db.RoomScenes.SingleAsync(s => s.RoomId == bare);
|
||||||
|
Assert.Equal((0, "Home"), (home.OrderIndex, home.Name));
|
||||||
|
var names = await db.RoomScenes.Where(s => s.RoomId == blank).OrderBy(s => s.OrderIndex).Select(s => s.Name).ToListAsync();
|
||||||
|
Assert.Equal(new[] { "Home", "Subroom 2" }, names);
|
||||||
|
|
||||||
|
// Idempotent.
|
||||||
|
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Room_level_restore_also_restores_the_entry_sub_room()
|
||||||
|
{
|
||||||
|
var owner = await PlayerAsync();
|
||||||
|
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home", "Back" });
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
await db.RoomScenes.Where(s => s.RoomId == roomId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.DataBlobName, "blob-old"));
|
||||||
|
db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = roomId, BlobName = "blob-new", UploadedByPlayerId = owner.PlayerId });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var api = Client("api", owner);
|
||||||
|
using var response = await api.PostAsync("/api/rooms/v1/restore", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["roomId"] = roomId.ToString(), ["blobName"] = "blob-new",
|
||||||
|
}));
|
||||||
|
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
await using var check = _factory.Services.CreateAsyncScope();
|
||||||
|
var checkDb = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var blobs = await checkDb.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
|
||||||
|
.OrderBy(s => s.OrderIndex).Select(s => s.DataBlobName).ToListAsync();
|
||||||
|
// The entry sub-room follows the restore; other sub-rooms keep theirs.
|
||||||
|
Assert.Equal(new[] { "blob-new", "blob-old" }, blobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Startup_repair_restores_a_missing_entry_sub_room_next_to_others()
|
||||||
|
{
|
||||||
|
var roomId = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "Home", "Back" });
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
await db.RoomScenes.Where(s => s.RoomId == roomId && s.OrderIndex == 0).ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||||
|
var (added, _) = await service.RepairRoomScenesAsync();
|
||||||
|
Assert.True(added >= 1);
|
||||||
|
|
||||||
|
var scenes = await db.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
|
||||||
|
.OrderBy(s => s.OrderIndex).Select(s => new { s.OrderIndex, s.Name }).ToListAsync();
|
||||||
|
Assert.Equal(new[] { (0, "Home"), (1, "Back") }, scenes.Select(s => (s.OrderIndex, s.Name)));
|
||||||
|
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Instance_browser_lists_the_private_instance_with_its_players_for_allowed_callers()
|
||||||
|
{
|
||||||
|
var owner = await PlayerAsync();
|
||||||
|
var coOwner = await PlayerAsync();
|
||||||
|
var stranger = await PlayerAsync();
|
||||||
|
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home" });
|
||||||
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
db.RoomRoles.Add(new RoomRoleEntity
|
||||||
|
{
|
||||||
|
RoomId = roomId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ownerMatch = Client("match", owner);
|
||||||
|
var joined = await PostFormAsync(ownerMatch, $"/matchmake/room/{roomId}");
|
||||||
|
var instanceId = joined.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64();
|
||||||
|
|
||||||
|
foreach (var viewer in new[] { owner, coOwner })
|
||||||
|
{
|
||||||
|
using var match = Client("match", viewer);
|
||||||
|
var list = await GetJsonAsync(match, $"/room/{roomId}/instances");
|
||||||
|
var row = list.EnumerateArray().Single();
|
||||||
|
Assert.Equal(instanceId, row.GetProperty("roomInstanceId").GetInt64());
|
||||||
|
Assert.Equal(roomId, row.GetProperty("roomId").GetInt64());
|
||||||
|
Assert.Equal(1, row.GetProperty("subRoomId").GetInt64());
|
||||||
|
Assert.False(row.GetProperty("isFull").GetBoolean());
|
||||||
|
Assert.Equal(new[] { (int)owner.PlayerId },
|
||||||
|
row.GetProperty("playerIds").EnumerateArray().Select(p => p.GetInt32()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Someone with no way into the private room is not shown it.
|
||||||
|
using var strangerMatch = Client("match", stranger);
|
||||||
|
var hidden = await GetJsonAsync(strangerMatch, $"/room/{roomId}/instances");
|
||||||
|
Assert.Empty(hidden.EnumerateArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Private_instance_visibility_rule()
|
||||||
|
{
|
||||||
|
var row = new PrivateInstanceEntity { Id = 5, OwnerPlayerId = 3 };
|
||||||
|
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, true, Array.Empty<long>(), row, false));
|
||||||
|
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, new long[] { 9 }, row, false));
|
||||||
|
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(3, false, Array.Empty<long>(), row, false));
|
||||||
|
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, true));
|
||||||
|
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, false));
|
||||||
|
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(0, true, Array.Empty<long>(), row, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<GameClientSession> PlayerAsync()
|
||||||
|
{
|
||||||
|
using var setup = Client("api");
|
||||||
|
return await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<long> SeedRoomAsync(long creatorId, int accessibility, string[] scenes, bool isAg = false)
|
||||||
|
{
|
||||||
|
var roomId = 9_200_000 + Random.Shared.Next(1, 99_999);
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
db.Rooms.Add(new RoomEntity
|
||||||
|
{
|
||||||
|
Id = roomId,
|
||||||
|
Name = $"Subs{Guid.NewGuid():N}"[..18],
|
||||||
|
CreatorPlayerId = creatorId,
|
||||||
|
ImageName = RoomService.DefaultRoomImageName,
|
||||||
|
Accessibility = accessibility,
|
||||||
|
CloningAllowed = true,
|
||||||
|
IsAGRoom = isAg,
|
||||||
|
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||||
|
});
|
||||||
|
for (var i = 0; i < scenes.Length; i++)
|
||||||
|
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = i, Name = scenes[i] });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return roomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> PostFormAsync(HttpClient client, string path)
|
||||||
|
{
|
||||||
|
using var response = await client.PostAsync(path, new FormUrlEncodedContent(new Dictionary<string, string>()));
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
|
||||||
|
return JsonDocument.Parse(body).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||||
|
{
|
||||||
|
using var response = await client.GetAsync(path);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||||
|
return JsonDocument.Parse(body).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
if (session is not null)
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -523,3 +523,45 @@ POST thread/{0}/snooze social-clubs-chat
|
|||||||
GET unity_assets/{0}/{1}/{2} inventions-media
|
GET unity_assets/{0}/{1}/{2} inventions-media
|
||||||
GET voice/config moderation-safety
|
GET voice/config moderation-safety
|
||||||
GET voice/requiresModeration moderation-safety
|
GET voice/requiresModeration moderation-safety
|
||||||
|
# Requests the plain route-literal sweep missed. Each verified in the ISIL:
|
||||||
|
# - leading-slash literals (RecNet.Runtime): LCOCDKINDMN_NestedType_CIPKPLFLIPC.txt:218,
|
||||||
|
# PBOGNAPEBAI_NestedType_JKNCNOHGPHK.txt:251 (GET under 100 ids, POST at 100+),
|
||||||
|
# NHILNDLLLHH.txt:329/801, OKOOJMAOKFE.txt:561, MNANGNGKKDA_NestedType_JAMKPNKPJPD.txt:179
|
||||||
|
# - Assembly-CSharp CDN fetch: JFDPIJFDHAD.txt:1432 (/sigs/<id>, boot file-signature check)
|
||||||
|
# - DELETE subscription/{0}: IKMMOCKDKAF.txt LPFOPPHGDHG (verb 4)
|
||||||
|
# - account/me/{field}: composed at runtime by OPMAPIOEIFG.OBAGJBBIFGB("account/me/" + field);
|
||||||
|
# verbs from each caller (username/displayname/bio/birthday/emoji/personalpronouns/identityflags/bannerimage PUT=3,
|
||||||
|
# email/phone/confirmphone POST=2).
|
||||||
|
GET api/playerReputation/v2/bulk identity-account
|
||||||
|
POST api/playerReputation/v2/bulk identity-account
|
||||||
|
GET api/players/v2/progression/bulk identity-account
|
||||||
|
POST api/players/v2/progression/bulk identity-account
|
||||||
|
GET strings config-telemetry
|
||||||
|
GET config/{0} config-telemetry
|
||||||
|
GET data/{0} config-telemetry
|
||||||
|
GET sigs/{0} config-telemetry
|
||||||
|
DELETE subscription/{0} commerce-subscription
|
||||||
|
PUT account/me/username identity-account
|
||||||
|
PUT account/me/displayname identity-account
|
||||||
|
PUT account/me/bio identity-account
|
||||||
|
PUT account/me/birthday identity-account
|
||||||
|
POST account/me/email identity-account
|
||||||
|
POST account/me/phone identity-account
|
||||||
|
PUT account/me/emoji identity-account
|
||||||
|
PUT account/me/personalpronouns identity-account
|
||||||
|
PUT account/me/identityflags identity-account
|
||||||
|
PUT account/me/bannerimage identity-account
|
||||||
|
POST account/me/confirmphone identity-account
|
||||||
|
# Routes composed from a base literal plus a format/segment (BPAIBFBKKCN.txt:51-53, HPEBLAHNBLC.txt:38-40,
|
||||||
|
# and the LPPEFJMCJFN URL builder in LINKIECPDML.txt:677-737).
|
||||||
|
GET api/itemWishlists/v1/wishlist/me avatar-inventory
|
||||||
|
GET api/itemWishlists/v1/wishlist/{0} avatar-inventory
|
||||||
|
PUT api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||||
|
DELETE api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||||
|
GET api/roomEarningsDistributions/v1/earningsDistribution/{0} progression-misc
|
||||||
|
GET api/incentivizedreferrals/referrals progression-misc
|
||||||
|
# Contact lookup (OPMAPIOEIFG.txt:11590-11620) and composed paths (FFFIMAGLKEG.txt:137, NOGJJIIENIL.txt:29).
|
||||||
|
POST account/bulk/email identity-account
|
||||||
|
POST account/bulk/phone identity-account
|
||||||
|
POST api/avatar/v2/gifts/consume avatar-inventory
|
||||||
|
GET video/{0} config-telemetry
|
||||||
|
|||||||
|
Can't render this file because it contains an unexpected character in line 532 and column 73.
|
@@ -57,17 +57,7 @@ public class AccountApiController(
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Error = string.Empty,
|
Error = string.Empty,
|
||||||
Account = new RecNetAccount
|
Account = AccountDtos.Public(player),
|
||||||
{
|
|
||||||
AccountId = (int)player.Id,
|
|
||||||
RawUsername = player.Username,
|
|
||||||
Username = player.Username,
|
|
||||||
DisplayName = player.DisplayName ?? player.Username,
|
|
||||||
ProfileImage = string.Empty,
|
|
||||||
TreatAsJunior = false,
|
|
||||||
HasBirthday = true,
|
|
||||||
Platforms = 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,22 +67,7 @@ public class AccountApiController(
|
|||||||
{
|
{
|
||||||
var player = await GetCurrentPlayerAsync();
|
var player = await GetCurrentPlayerAsync();
|
||||||
if (player is null) return Unauthorized();
|
if (player is null) return Unauthorized();
|
||||||
return Ok(new RecNetSelfAccount
|
return Ok(AccountDtos.Self(player));
|
||||||
{
|
|
||||||
AccountId = (int)player.Id,
|
|
||||||
RawUsername = player.Username,
|
|
||||||
Username = player.Username,
|
|
||||||
DisplayName = player.DisplayName ?? player.Username,
|
|
||||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
|
||||||
TreatAsJunior = false,
|
|
||||||
HasBirthday = true,
|
|
||||||
Platforms = 1,
|
|
||||||
Email = player.Email ?? string.Empty,
|
|
||||||
Phone = string.Empty,
|
|
||||||
Birthday = new DateTime(2000, 1, 1),
|
|
||||||
JuniorState = 0,
|
|
||||||
ParentAccountId = null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -50,7 +50,18 @@ public class AvatarGiftsController(
|
|||||||
return Ok(rows.Select(ToWire));
|
return Ok(rows.Select(ToWire));
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message);
|
/// <summary>Body keys are the client's AFGEDDANEKP param literals
|
||||||
|
/// (GiftContext / IsGameGift / AlternateGiftContext / Message). A class
|
||||||
|
/// with settable properties, not a positional record, because
|
||||||
|
/// <see cref="Binding.FormOrJsonModelBinder"/> constructs it empty and
|
||||||
|
/// fills it.</summary>
|
||||||
|
public sealed class GenerateGiftRequest
|
||||||
|
{
|
||||||
|
public int? GiftContext { get; set; }
|
||||||
|
public bool? IsGameGift { get; set; }
|
||||||
|
public int? AlternateGiftContext { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of-
|
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of-
|
||||||
/// activity gift box (chest). For a game/quest gift (IsGameGift) the
|
/// activity gift box (chest). For a game/quest gift (IsGameGift) the
|
||||||
@@ -64,8 +75,13 @@ public class AvatarGiftsController(
|
|||||||
// POST 404'd, breaking reward chests on that path). Same request body.
|
// POST 404'd, breaking reward chests on that path). Same request body.
|
||||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||||
[HttpPost("api/avatar/v3/gifts/generate")]
|
[HttpPost("api/avatar/v3/gifts/generate")]
|
||||||
|
// The client adds each field with AFGEDDANEKP (FFFIMAGLKEG.txt:1930-2013,
|
||||||
|
// POST = ordinal 2), which goes out form-urlencoded: a live trace showed
|
||||||
|
// `GiftContext=12000&IsGameGift=True&Message=…` answered 415 while this
|
||||||
|
// was [FromBody]. The form-or-JSON binder accepts either encoding.
|
||||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
public async Task<IActionResult> Generate(
|
||||||
|
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] GenerateGiftRequest? req)
|
||||||
{
|
{
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var isGameGift = req?.IsGameGift ?? false;
|
var isGameGift = req?.IsGameGift ?? false;
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DorkNet.Server.Auth;
|
using DorkNet.Server.Auth;
|
||||||
|
using DorkNet.Server.Controllers.API.Avatar.V2;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
using DorkNet.Server.Data.Entities;
|
using DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
namespace DorkNet.Server.Controllers.API.Avatar.V3;
|
namespace DorkNet.Server.Controllers.API.Avatar.V3;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
|
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Client wire
|
||||||
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
|
/// type BEPMJICDNDD, whose converter IMMOJHBENFI registers exactly
|
||||||
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
|
/// <c>Slot, PreviewImageName, OutfitSelections, OutfitSelectionsV2,
|
||||||
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
|
/// FaceFeatures, SkinColor, HairColor, CustomAvatarItems</c>
|
||||||
|
/// (RecNet.Runtime/IMMOJHBENFI.txt:587-766 reader, :781-910 writer).
|
||||||
|
/// CustomAvatarItems is a list of BGNNOMBFMLH
|
||||||
|
/// <c>{CustomAvatarItemId, BodyPart}</c>, the same element the avatar
|
||||||
|
/// settings (<c>api/avatar/v2</c>) use.
|
||||||
///
|
///
|
||||||
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
|
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
|
||||||
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
|
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
|
||||||
@@ -38,12 +43,27 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
||||||
/// Body is the SavedOutfit shape directly.</summary>
|
/// Verb ordinal 2 = POST with a raw JSON body of one serialised
|
||||||
|
/// BEPMJICDNDD (RecNet.Runtime/FFFIMAGLKEG.txt:4071-4095, NCILAADFJMM).
|
||||||
|
///
|
||||||
|
/// Parsed by hand rather than <c>[FromBody] SavedOutfitDto</c>: the old
|
||||||
|
/// DTO silently dropped OutfitSelectionsV2 and CustomAvatarItems, and the
|
||||||
|
/// 2023 client wears OutfitSelectionsV2 whenever it is non-empty
|
||||||
|
/// (BEPMJICDNDD.NLGCNDMLACA, BEPMJICDNDD.txt:237-266, falls back to
|
||||||
|
/// OutfitSelections only when V2 is empty). Loading a saved slot
|
||||||
|
/// therefore restored a stale V1 selection (or nothing) and lost every
|
||||||
|
/// custom shirt in it.</summary>
|
||||||
[HttpPost("api/avatar/v3/saved/set")]
|
[HttpPost("api/avatar/v3/saved/set")]
|
||||||
[HttpPost("api/avatar/v2/saved/set")]
|
[HttpPost("api/avatar/v2/saved/set")]
|
||||||
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
|
public async Task<IActionResult> Set()
|
||||||
{
|
{
|
||||||
|
string raw;
|
||||||
|
using (var reader = new StreamReader(Request.Body))
|
||||||
|
raw = await reader.ReadToEndAsync();
|
||||||
|
|
||||||
|
var body = ParseOutfit(raw);
|
||||||
if (body is null) return BadRequest("missing body");
|
if (body is null) return BadRequest("missing body");
|
||||||
|
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||||
if (avatar is null)
|
if (avatar is null)
|
||||||
@@ -59,6 +79,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
|||||||
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
|
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
|
||||||
list.Add(body);
|
list.Add(body);
|
||||||
}
|
}
|
||||||
|
list.Sort((a, b) => a.Slot.CompareTo(b.Slot));
|
||||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||||
avatar.UpdatedAt = DateTime.UtcNow;
|
avatar.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -81,12 +102,91 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
|||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Read one outfit object, matching keys case-insensitively
|
||||||
|
/// (the client reader accepts Pascal/camel/lower spellings, so any of
|
||||||
|
/// them is a valid write). String fields tolerate null; a nested
|
||||||
|
/// object/array is kept as its raw JSON text, mirroring how
|
||||||
|
/// <c>avatar/v2/set</c> treats the same fields. Returns null for a body
|
||||||
|
/// that is not a JSON object.</summary>
|
||||||
|
internal static SavedOutfitDto? ParseOutfit(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(raw);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object) return null;
|
||||||
|
|
||||||
|
var dto = new SavedOutfitDto();
|
||||||
|
foreach (var prop in root.EnumerateObject())
|
||||||
|
{
|
||||||
|
switch (prop.Name.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "slot":
|
||||||
|
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var slot)) dto.Slot = slot;
|
||||||
|
else if (prop.Value.ValueKind == JsonValueKind.String && int.TryParse(prop.Value.GetString(), out slot)) dto.Slot = slot;
|
||||||
|
break;
|
||||||
|
case "previewimagename": dto.PreviewImageName = AsString(prop.Value); break;
|
||||||
|
case "outfitselections": dto.OutfitSelections = AsString(prop.Value); break;
|
||||||
|
case "outfitselectionsv2": dto.OutfitSelectionsV2 = AsString(prop.Value); break;
|
||||||
|
case "facefeatures": dto.FaceFeatures = AsString(prop.Value); break;
|
||||||
|
case "skincolor": dto.SkinColor = AsString(prop.Value); break;
|
||||||
|
case "haircolor": dto.HairColor = AsString(prop.Value); break;
|
||||||
|
case "customavataritems": dto.CustomAvatarItems = AsCustomItems(prop.Value); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string AsString(JsonElement v) => v.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => v.GetString() ?? string.Empty,
|
||||||
|
JsonValueKind.Null or JsonValueKind.Undefined => string.Empty,
|
||||||
|
_ => v.GetRawText(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>BGNNOMBFMLH list. Elements without a parseable guid are
|
||||||
|
/// dropped instead of failing the whole save.</summary>
|
||||||
|
private static List<CustomAvatarItemRefDto> AsCustomItems(JsonElement v)
|
||||||
|
{
|
||||||
|
var result = new List<CustomAvatarItemRefDto>();
|
||||||
|
if (v.ValueKind != JsonValueKind.Array) return result;
|
||||||
|
foreach (var el in v.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||||
|
Guid? id = null;
|
||||||
|
byte bodyPart = 0;
|
||||||
|
foreach (var p in el.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (p.Name.Equals("CustomAvatarItemId", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& p.Value.ValueKind == JsonValueKind.String
|
||||||
|
&& Guid.TryParse(p.Value.GetString(), out var g))
|
||||||
|
id = g;
|
||||||
|
else if (p.Name.Equals("BodyPart", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& p.Value.ValueKind == JsonValueKind.Number
|
||||||
|
&& p.Value.TryGetByte(out var b))
|
||||||
|
bodyPart = b;
|
||||||
|
}
|
||||||
|
if (id is { } guid) result.Add(new CustomAvatarItemRefDto { CustomAvatarItemId = guid, BodyPart = bodyPart });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static List<SavedOutfitDto> ParseList(string? json)
|
private static List<SavedOutfitDto> ParseList(string? json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
var list = JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||||
|
// Rows written before CustomAvatarItems existed carry no key;
|
||||||
|
// keep the wire array non-null.
|
||||||
|
foreach (var o in list) o.CustomAvatarItems ??= new();
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
catch { return new(); }
|
catch { return new(); }
|
||||||
}
|
}
|
||||||
@@ -97,7 +197,14 @@ public sealed class SavedOutfitDto
|
|||||||
[JsonPropertyName("Slot")] public int Slot { get; set; }
|
[JsonPropertyName("Slot")] public int Slot { get; set; }
|
||||||
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
|
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
|
||||||
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
|
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
|
||||||
|
/// <summary>The selection the 2023 client actually wears when non-empty
|
||||||
|
/// (BEPMJICDNDD.NLGCNDMLACA). Rows saved before this field existed
|
||||||
|
/// deserialize to "" and the client falls back to OutfitSelections.</summary>
|
||||||
|
[JsonPropertyName("OutfitSelectionsV2")] public string OutfitSelectionsV2 { get; set; } = string.Empty;
|
||||||
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
|
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
|
||||||
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
|
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
|
||||||
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
|
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
|
||||||
|
/// <summary>Custom shirts worn in this slot. Always an array on the wire
|
||||||
|
/// (the client's ctor allocates an empty list, BEPMJICDNDD.txt:321-360).</summary>
|
||||||
|
[JsonPropertyName("CustomAvatarItems")] public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,15 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4;
|
|||||||
/// makes every store item appear owned. We instead intersect the
|
/// makes every store item appear owned. We instead intersect the
|
||||||
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
|
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
|
||||||
/// catalog so the watch sees only items the player actually owns.
|
/// catalog so the watch sees only items the player actually owns.
|
||||||
/// Anonymous calls return [].
|
///
|
||||||
|
/// DEFAULT-UNLOCKED items: the 2023 client REPLACES its unlocked set with
|
||||||
|
/// each list it downloads (OutfitManager.GFEIDFKKNMO clears field +0x70,
|
||||||
|
/// then refills it; IsAvatarItemUnlocked is a HashSet lookup on that
|
||||||
|
/// field). It never merges in its own baked default list, so the server
|
||||||
|
/// must include the free starter items (avatar_item_lookup.json
|
||||||
|
/// <c>default_unlocked_variants</c>) in every response. Without them a
|
||||||
|
/// default item such as Beard Close showed a price in the outfit menu.
|
||||||
|
/// Anonymous calls (<c>defaultunlocked</c> before login) get the defaults.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public class AvatarItemsController(
|
public class AvatarItemsController(
|
||||||
@@ -107,10 +115,10 @@ public class AvatarItemsController(
|
|||||||
// reverts to the real per-player wardrobe below. Bounded by the same
|
// reverts to the real per-player wardrobe below. Bounded by the same
|
||||||
// maxItems ceiling so a future watch build can't be over-fed.
|
// maxItems ceiling so a future watch build can't be over-fed.
|
||||||
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
|
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
|
||||||
return Ok(BuildEntireCatalogOwned(maxItems));
|
return await WithStoreInfoAsync(BuildEntireCatalogOwned(maxItems));
|
||||||
|
|
||||||
var pid = this.CurrentPlayerId();
|
var pid = this.CurrentPlayerId();
|
||||||
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
|
if (pid is not long me) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
|
||||||
|
|
||||||
var avatar = await db.Avatars
|
var avatar = await db.Avatars
|
||||||
.Where(a => a.PlayerId == me)
|
.Where(a => a.PlayerId == me)
|
||||||
@@ -126,7 +134,7 @@ public class AvatarItemsController(
|
|||||||
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
|
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>());
|
if (owned.Count == 0 && ownedHairDyes.Count == 0) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
|
||||||
|
|
||||||
var catalog = _catalog.Value;
|
var catalog = _catalog.Value;
|
||||||
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
|
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
|
||||||
@@ -142,7 +150,10 @@ public class AvatarItemsController(
|
|||||||
.ThenByDescending(x => x.OriginalIndex)
|
.ThenByDescending(x => x.OriginalIndex)
|
||||||
.Select(x => x.Entry);
|
.Select(x => x.Entry);
|
||||||
|
|
||||||
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
|
// Room is kept for the default-unlocked items so a large wardrobe
|
||||||
|
// can't push them out (they'd show as buyable again).
|
||||||
|
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count - catalog.DefaultUnlocked.Count);
|
||||||
|
var emittedBase = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
var perSlot = new Dictionary<int, int>();
|
var perSlot = new Dictionary<int, int>();
|
||||||
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
|
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
|
||||||
foreach (var entry in ordered)
|
foreach (var entry in ordered)
|
||||||
@@ -156,12 +167,15 @@ public class AvatarItemsController(
|
|||||||
// miss and silently drop every owned colored item (the contest
|
// miss and silently drop every owned colored item (the contest
|
||||||
// hat "I bought it but it never shows in my wardrobe" bug).
|
// hat "I bought it but it never shows in my wardrobe" bug).
|
||||||
if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue;
|
if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue;
|
||||||
|
if (item.IsBaseAvatarItem) continue; // custom-clothing template, not clothing
|
||||||
|
|
||||||
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
|
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
|
||||||
perSlot.TryGetValue(slot, out var count);
|
perSlot.TryGetValue(slot, out var count);
|
||||||
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue;
|
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue;
|
||||||
|
|
||||||
perSlot[slot] = count + 1;
|
perSlot[slot] = count + 1;
|
||||||
|
if (string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase))
|
||||||
|
emittedBase.Add(entry.BaseGuid);
|
||||||
// Preserve the bought color: a bare entry (FullDesc == BaseGuid)
|
// Preserve the bought color: a bare entry (FullDesc == BaseGuid)
|
||||||
// emits the catalog DTO as-is ("{guid},,,"); a colored entry
|
// emits the catalog DTO as-is ("{guid},,,"); a colored entry
|
||||||
// overrides AvatarItemDesc with the stored swatch/mask desc so
|
// overrides AvatarItemDesc with the stored swatch/mask desc so
|
||||||
@@ -173,7 +187,12 @@ public class AvatarItemsController(
|
|||||||
{
|
{
|
||||||
AvatarItemType = item.AvatarItemType,
|
AvatarItemType = item.AvatarItemType,
|
||||||
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
||||||
AvatarItemId = item.AvatarItemId,
|
// Hash the FULL desc, as the toggle-on path does: a
|
||||||
|
// player can own the base item and several colorways of
|
||||||
|
// it at once (entries dedupe by FullDesc), and reusing the
|
||||||
|
// base id made them collide in the client's id-keyed
|
||||||
|
// wardrobe.
|
||||||
|
AvatarItemId = StableAvatarItemIdFull(ToWatchDesc(entry.FullDesc)),
|
||||||
PlatformMask = item.PlatformMask,
|
PlatformMask = item.PlatformMask,
|
||||||
FriendlyName = item.FriendlyName,
|
FriendlyName = item.FriendlyName,
|
||||||
Tooltip = item.Tooltip,
|
Tooltip = item.Tooltip,
|
||||||
@@ -184,6 +203,12 @@ public class AvatarItemsController(
|
|||||||
if (result.Count >= maxItems) break;
|
if (result.Count >= maxItems) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var guid in catalog.DefaultUnlocked)
|
||||||
|
{
|
||||||
|
if (result.Count >= maxItems) break;
|
||||||
|
if (emittedBase.Add(guid)) result.Add(catalog.Items[guid]);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var slug in ownedHairDyes)
|
foreach (var slug in ownedHairDyes)
|
||||||
{
|
{
|
||||||
if (result.Count >= maxItems) break;
|
if (result.Count >= maxItems) break;
|
||||||
@@ -202,7 +227,42 @@ public class AvatarItemsController(
|
|||||||
IsBaseAvatarItem = false,
|
IsBaseAvatarItem = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Ok(result);
|
return await WithStoreInfoAsync(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Give each owned row the same FriendlyName, Tooltip and
|
||||||
|
/// Rarity its shop tile shows. The client keeps this list's metadata
|
||||||
|
/// per item, and the catalog dump only carries asset names (e.g.
|
||||||
|
/// "(Shirt_Winter)" for colour variants) with no rarity or tooltip, so
|
||||||
|
/// the owned list disagreed with the shop for most items. Rows with no
|
||||||
|
/// shop tile keep the catalog name. Catalog DTOs are shared, so matched
|
||||||
|
/// rows are copied, never mutated.</summary>
|
||||||
|
private async Task<List<UnlockedAvatarItemDto>> WithStoreInfoAsync(List<UnlockedAvatarItemDto> rows)
|
||||||
|
{
|
||||||
|
var info = await StoreService.LoadAvatarItemStoreInfoAsync(db);
|
||||||
|
if (info.Count == 0) return rows;
|
||||||
|
return rows.Select(r => info.TryGetValue(r.AvatarItemDesc, out var s)
|
||||||
|
? new UnlockedAvatarItemDto
|
||||||
|
{
|
||||||
|
AvatarItemType = r.AvatarItemType,
|
||||||
|
AvatarItemDesc = r.AvatarItemDesc,
|
||||||
|
AvatarItemId = r.AvatarItemId,
|
||||||
|
PlatformMask = r.PlatformMask,
|
||||||
|
FriendlyName = string.IsNullOrWhiteSpace(s.FriendlyName) ? r.FriendlyName : s.FriendlyName,
|
||||||
|
Tooltip = s.Tooltip,
|
||||||
|
Rarity = s.Rarity,
|
||||||
|
TagList = r.TagList,
|
||||||
|
IsBaseAvatarItem = r.IsBaseAvatarItem,
|
||||||
|
}
|
||||||
|
: r).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The free items every player owns
|
||||||
|
/// (<c>default_unlocked_variants</c>), capped at <paramref name="maxItems"/>.</summary>
|
||||||
|
private static List<UnlockedAvatarItemDto> BuildDefaultUnlocked(int maxItems)
|
||||||
|
{
|
||||||
|
var catalog = _catalog.Value;
|
||||||
|
return catalog.DefaultUnlocked.Take(maxItems).Select(g => catalog.Items[g]).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The full owned set when the "everyone owns all avatar
|
/// <summary>The full owned set when the "everyone owns all avatar
|
||||||
@@ -218,6 +278,8 @@ public class AvatarItemsController(
|
|||||||
foreach (var item in catalog.Items.Values)
|
foreach (var item in catalog.Items.Values)
|
||||||
{
|
{
|
||||||
if (result.Count >= maxItems) return result;
|
if (result.Count >= maxItems) return result;
|
||||||
|
// Custom-clothing templates aren't ownable clothing.
|
||||||
|
if (item.IsBaseAvatarItem) continue;
|
||||||
result.Add(item);
|
result.Add(item);
|
||||||
}
|
}
|
||||||
// Colored variants aren't in the base avatar_item_lookup catalog, so
|
// Colored variants aren't in the base avatar_item_lookup catalog, so
|
||||||
@@ -267,14 +329,33 @@ public class AvatarItemsController(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The custom-clothing templates ("base avatar items") and
|
||||||
|
/// their RecNet AvatarItemIds, as the real service served them. The
|
||||||
|
/// client flags every row of <c>defaultbaseavataritems</c> as a base
|
||||||
|
/// item (AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId in
|
||||||
|
/// OutfitManager.AODPBDNAGII) and leaves base items out of the owned
|
||||||
|
/// rebuild (MBPOFMEHDIJ), so they never read as owned clothing. This
|
||||||
|
/// endpoint used to return the first 100 catalog items, which turned
|
||||||
|
/// ordinary items (80sHipHopJacket, Beard Close, ...) into templates
|
||||||
|
/// that showed a price even when owned and equipped.</summary>
|
||||||
|
public static readonly IReadOnlyDictionary<string, int> BaseAvatarItemIds =
|
||||||
|
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["c5d70cb4-71dd-4fe4-b719-34fe2073c611"] = 2184, // UGCTee_Shirt
|
||||||
|
["95a519de-f2cb-429c-b014-508477f20d42"] = 2918, // UGCPulloverHoodie_Shirt
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsBaseAvatarItemGuid(string? guid) =>
|
||||||
|
!string.IsNullOrEmpty(guid) && BaseAvatarItemIds.ContainsKey(guid);
|
||||||
|
|
||||||
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
|
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public IActionResult DefaultBaseAvatarItems()
|
public IActionResult DefaultBaseAvatarItems()
|
||||||
{
|
{
|
||||||
var rows = _catalog.Value.Items.Values
|
var items = _catalog.Value.Items;
|
||||||
.OrderBy(i => i.AvatarItemType)
|
var rows = BaseAvatarItemIds.Keys
|
||||||
.ThenBy(i => i.FriendlyName)
|
.Where(items.ContainsKey)
|
||||||
.Take(100)
|
.Select(g => items[g])
|
||||||
.ToList();
|
.ToList();
|
||||||
return Ok(rows);
|
return Ok(rows);
|
||||||
}
|
}
|
||||||
@@ -290,6 +371,10 @@ public class AvatarItemsController(
|
|||||||
/// data against the same safe list.</summary>
|
/// data against the same safe list.</summary>
|
||||||
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
|
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
|
||||||
|
|
||||||
|
/// <summary>Base GUIDs of the free items every player owns
|
||||||
|
/// (<c>default_unlocked_variants</c> in avatar_item_lookup.json).</summary>
|
||||||
|
public static IReadOnlyList<string> DefaultUnlockedGuids => _catalog.Value.DefaultUnlocked;
|
||||||
|
|
||||||
/// <summary>Master catalog of all renderable items, keyed by
|
/// <summary>Master catalog of all renderable items, keyed by
|
||||||
/// GUID. Used by callers that need item metadata (slot type,
|
/// GUID. Used by callers that need item metadata (slot type,
|
||||||
/// name, hair-color flags) outside of the per-player ownership
|
/// name, hair-color flags) outside of the per-player ownership
|
||||||
@@ -327,23 +412,24 @@ public class AvatarItemsController(
|
|||||||
foreach (var guid in ParseInventory(invJson))
|
foreach (var guid in ParseInventory(invJson))
|
||||||
owned.Add(guid);
|
owned.Add(guid);
|
||||||
}
|
}
|
||||||
|
// Free default items are owned by everyone, logged in or not.
|
||||||
|
owned.UnionWith(_catalog.Value.DefaultUnlocked);
|
||||||
|
|
||||||
|
// The client parses this as List<IFNGBPJGINL> — the SAME element as
|
||||||
|
// v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by
|
||||||
|
// CLIANCNHNIE which registers TagList / AvatarItemId /
|
||||||
|
// IsBaseAvatarItem too (CLIANCNHNIE.txt). The old 5-key subset left
|
||||||
|
// every locked row at AvatarItemId 0 with a null TagList, so ship
|
||||||
|
// the full catalog DTO.
|
||||||
var catalog = _catalog.Value.Items;
|
var catalog = _catalog.Value.Items;
|
||||||
var rows = requested
|
var rows = requested
|
||||||
.Where(guid => !owned.Contains(guid))
|
.Where(guid => !owned.Contains(guid))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
|
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
|
||||||
.Where(item => item is not null)
|
.Where(item => item is not null)
|
||||||
.Select(item => new
|
.Select(item => item!)
|
||||||
{
|
.ToList();
|
||||||
item!.AvatarItemType,
|
return Ok(await WithStoreInfoAsync(rows));
|
||||||
item.AvatarItemDesc,
|
|
||||||
item.FriendlyName,
|
|
||||||
item.Tooltip,
|
|
||||||
item.Rarity,
|
|
||||||
})
|
|
||||||
.ToArray();
|
|
||||||
return Ok(rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
|
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
|
||||||
@@ -481,7 +567,8 @@ public class AvatarItemsController(
|
|||||||
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
|
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
|
||||||
IReadOnlySet<string> SafeGuids,
|
IReadOnlySet<string> SafeGuids,
|
||||||
IReadOnlyDictionary<int, List<string>> BySlot,
|
IReadOnlyDictionary<int, List<string>> BySlot,
|
||||||
IReadOnlyDictionary<string, int> SlotByGuid);
|
IReadOnlyDictionary<string, int> SlotByGuid,
|
||||||
|
IReadOnlyList<string> DefaultUnlocked);
|
||||||
|
|
||||||
private static CatalogData LoadCatalog()
|
private static CatalogData LoadCatalog()
|
||||||
{
|
{
|
||||||
@@ -508,7 +595,8 @@ public class AvatarItemsController(
|
|||||||
new Dictionary<string, UnlockedAvatarItemDto>(),
|
new Dictionary<string, UnlockedAvatarItemDto>(),
|
||||||
new HashSet<string>(),
|
new HashSet<string>(),
|
||||||
new Dictionary<int, List<string>>(),
|
new Dictionary<int, List<string>>(),
|
||||||
new Dictionary<string, int>());
|
new Dictionary<string, int>(),
|
||||||
|
Array.Empty<string>());
|
||||||
|
|
||||||
using var fs = System.IO.File.OpenRead(path);
|
using var fs = System.IO.File.OpenRead(path);
|
||||||
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
|
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
|
||||||
@@ -541,13 +629,13 @@ public class AvatarItemsController(
|
|||||||
// GUID as a color GUID and log "Can not find hair dye color".
|
// GUID as a color GUID and log "Can not find hair dye color".
|
||||||
AvatarItemType = AvatarItemType.Outfit,
|
AvatarItemType = AvatarItemType.Outfit,
|
||||||
AvatarItemDesc = row.Guid + ",,,",
|
AvatarItemDesc = row.Guid + ",,,",
|
||||||
AvatarItemId = StableAvatarItemId(row.Guid),
|
AvatarItemId = BaseAvatarItemIds.TryGetValue(row.Guid, out var baseId) ? baseId : StableAvatarItemId(row.Guid),
|
||||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||||
FriendlyName = pretty,
|
FriendlyName = pretty,
|
||||||
Tooltip = string.Empty,
|
Tooltip = string.Empty,
|
||||||
Rarity = 0,
|
Rarity = 0,
|
||||||
TagList = string.Empty,
|
TagList = string.Empty,
|
||||||
IsBaseAvatarItem = false,
|
IsBaseAvatarItem = IsBaseAvatarItemGuid(row.Guid),
|
||||||
};
|
};
|
||||||
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
||||||
bySlot[row.OutfitType] = lst = new List<string>();
|
bySlot[row.OutfitType] = lst = new List<string>();
|
||||||
@@ -555,15 +643,23 @@ public class AvatarItemsController(
|
|||||||
slotByGuid[row.Guid] = row.OutfitType;
|
slotByGuid[row.Guid] = row.OutfitType;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CatalogData(items, safe, bySlot, slotByGuid);
|
// Default-unlocked entries are all plain base items (no swatch, mask
|
||||||
|
// or decal) in the March dump; only keep ones the catalog can render.
|
||||||
|
var defaults = dump.DefaultUnlockedVariants
|
||||||
|
.Where(v => string.IsNullOrEmpty(v.Swatch) && string.IsNullOrEmpty(v.Mask) && string.IsNullOrEmpty(v.Decal))
|
||||||
|
.Select(v => v.Item)
|
||||||
|
.Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g) && !IsBaseAvatarItemGuid(g))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new CatalogData(items, safe, bySlot, slotByGuid, defaults);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned
|
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned
|
||||||
/// avatar item, derived from its base GUID (FNV-1a). The 2023 client
|
/// 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
|
/// keys the wardrobe by <c>AvatarItemId</c>, so it must be unique per
|
||||||
/// item and stable across restarts. Colored variants reuse their base
|
/// item and stable across restarts. Colored variants must NOT use this — a player can own the base and
|
||||||
/// item's id (a player owns the base OR a variant, never both in the
|
/// several colorways together; see <see cref="StableAvatarItemIdFull"/>.</summary>
|
||||||
/// list), so no collision.</summary>
|
|
||||||
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
|
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
|
||||||
{
|
{
|
||||||
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
|
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
|
||||||
@@ -618,6 +714,19 @@ public class AvatarItemsController(
|
|||||||
public List<string> SwatchGuids { get; set; } = new();
|
public List<string> SwatchGuids { get; set; } = new();
|
||||||
[JsonPropertyName("decal_guids")]
|
[JsonPropertyName("decal_guids")]
|
||||||
public List<string> DecalGuids { get; set; } = new();
|
public List<string> DecalGuids { get; set; } = new();
|
||||||
|
|
||||||
|
// The client config's defaultUnlockedVariants: free items every
|
||||||
|
// player owns.
|
||||||
|
[JsonPropertyName("default_unlocked_variants")]
|
||||||
|
public List<LookupVariant> DefaultUnlockedVariants { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LookupVariant
|
||||||
|
{
|
||||||
|
[JsonPropertyName("item")] public string Item { get; set; } = string.Empty;
|
||||||
|
[JsonPropertyName("mask")] public string Mask { get; set; } = string.Empty;
|
||||||
|
[JsonPropertyName("swatch")] public string Swatch { get; set; } = string.Empty;
|
||||||
|
[JsonPropertyName("decal")] public string Decal { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class LookupItem
|
private sealed class LookupItem
|
||||||
|
|||||||
@@ -268,8 +268,11 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
|
|||||||
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
|
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
|
||||||
///
|
///
|
||||||
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
|
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
|
||||||
/// (OPELMBNJHNO.txt:1375) returns the status-only <c>LDGADANDBIO</c>, not a
|
/// (OPELMBNJHNO.txt:1375) returns the untyped <c>LDGADANDBIO</c> promise, but
|
||||||
/// DTO — the body is never parsed, only the status code. Verb is <b>PUT</b>
|
/// it is built from the body parsed as a RecNetResult: the continuation
|
||||||
|
/// <c><UpdateLatestReadComment>b__9_1(DJMHAFPGLLN)</c>
|
||||||
|
/// (OPELMBNJHNO_NestedType___c.txt:333) null-derefs an empty body and
|
||||||
|
/// rejects on <c>Success</c>=false — see <see cref="RecNetResults"/>. Verb is <b>PUT</b>
|
||||||
/// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments
|
/// (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
|
/// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so
|
||||||
/// the request carries no body at all.
|
/// the request carries no body at all.
|
||||||
@@ -280,7 +283,7 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
|
|||||||
{
|
{
|
||||||
await SetLastReadAsync(Me, roomId, commentId);
|
await SetLastReadAsync(Me, roomId, commentId);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deletes a comment.
|
/// <summary>Deletes a comment.
|
||||||
|
|||||||
@@ -129,14 +129,19 @@ public class ConfigController(ConfigService configService) : ControllerBase
|
|||||||
/// Returns a broad default set so the picker is populated.</summary>
|
/// Returns a broad default set so the picker is populated.</summary>
|
||||||
[HttpGet("/emojiConfig/whitelistedEmojis")]
|
[HttpGet("/emojiConfig/whitelistedEmojis")]
|
||||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
public IActionResult WhitelistedEmojis() => Ok(new[]
|
public IActionResult WhitelistedEmojis() => Ok(WhitelistedEmojiList);
|
||||||
|
|
||||||
|
/// <summary>The emoji allow-list served above. Also the validation set
|
||||||
|
/// for <c>PUT account/me/emoji</c>, so a player can only pick an emoji
|
||||||
|
/// the client's own picker offered.</summary>
|
||||||
|
public static readonly IReadOnlyList<string> WhitelistedEmojiList = new[]
|
||||||
{
|
{
|
||||||
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
|
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
|
||||||
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
|
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
|
||||||
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
|
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
|
||||||
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
|
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
|
||||||
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
|
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
|
||||||
});
|
};
|
||||||
|
|
||||||
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
|
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
|
||||||
/// 2020.12 client deserializes this endpoint as a raw Boolean.
|
/// 2020.12 client deserializes this endpoint as a raw Boolean.
|
||||||
|
|||||||
@@ -371,11 +371,22 @@ public class CustomAvatarItemsController(
|
|||||||
var allowed = player is not null
|
var allowed = player is not null
|
||||||
&& !player.IsJunior
|
&& !player.IsJunior
|
||||||
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
||||||
// Bare JSON boolean, like the two sibling gates below: the client's
|
// NOT a bare boolean, unlike the two sibling gates below. The issuing
|
||||||
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK()
|
// method GGMDLMLJDDK (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route on
|
||||||
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an
|
// :6205) parses the body as DJMHAFPGLLN — {Boolean @0x10, String} —
|
||||||
// object body threw and the can-create gate blocked shirt creation.
|
// (generic token 0x188477F70 at :6216; Func<DJMHAFPGLLN,…> at :6223)
|
||||||
return Content(allowed ? "true" : "false", "application/json");
|
// and its success continuation HONEEPBHNLB reads the bool at +0x10
|
||||||
|
// (:9155-9216).
|
||||||
|
// DJMHAFPGLLN's converter LMCCNLLHLCJ registers exactly the keys
|
||||||
|
// "Success" and "Error" (LMCCNLLHLCJ.txt:191-254). A bare `true`
|
||||||
|
// failed that parse, so the client logged "Failed to query if local
|
||||||
|
// account can create Custom Avatar Items: An error occurred" and
|
||||||
|
// treated the account as unable to create shirts.
|
||||||
|
return Ok(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Success"] = allowed,
|
||||||
|
["Error"] = allowed ? null : "Custom avatar item creation is not available for this account.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("isCreationEnabled")]
|
[HttpGet("isCreationEnabled")]
|
||||||
@@ -386,22 +397,17 @@ public class CustomAvatarItemsController(
|
|||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
||||||
|
|
||||||
/// <summary>The client DTO (IHLMDLKOLNB) is an object with exactly one
|
/// <summary>Bare JSON integer. The client's IHLMDLKOLNB pricing config is
|
||||||
/// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere
|
/// built CLIENT-side: HCBOHHEACPK sends through FDKKOPAPDGF<Int32>
|
||||||
/// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are
|
/// (RecNet.Runtime/MPBLNLMCEDL.txt:6922 — the same generic token
|
||||||
/// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT
|
/// 0x18847B0B8 as the bare-Int32 itemOwnershipLimit call at :2981) and
|
||||||
/// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft
|
/// continues with <c><GetPricingConfig>b__69_0(Int32 minimumPrice)</c>
|
||||||
/// ignores the ones that don't match, and a total miss falls back to the
|
/// (MPBLNLMCEDL_NestedType___c.txt:286). An object body failed the Int32
|
||||||
/// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing.</summary>
|
/// parse, so the designer's public-price floor never loaded.</summary>
|
||||||
[HttpGet("minPriceForPublicItem")]
|
[HttpGet("minPriceForPublicItem")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public IActionResult MinPriceForPublicItem() => Ok(new
|
public IActionResult MinPriceForPublicItem()
|
||||||
{
|
=> Content(MinPublicItemPrice.ToString(System.Globalization.CultureInfo.InvariantCulture), "application/json");
|
||||||
MinPrice = MinPublicItemPrice,
|
|
||||||
Price = MinPublicItemPrice,
|
|
||||||
MinimumPrice = MinPublicItemPrice,
|
|
||||||
MinPriceForPublicItem = MinPublicItemPrice,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
|
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
|
||||||
/// saved in-progress shirt design. Verb ordinal 0 = GET
|
/// saved in-progress shirt design. Verb ordinal 0 = GET
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ public class FreeGiftsController(DorkNetDbContext db, NotificationService notifi
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
foreach (var row in created)
|
foreach (var row in created)
|
||||||
{
|
{
|
||||||
|
// The full gift package, as GET api/avatar/v2/gifts lists it: the
|
||||||
|
// client consumes a gift by its Id, which the old hand-built
|
||||||
|
// payload didn't carry.
|
||||||
await notifications.NotifyAsync(row.RecipientPlayerId,
|
await notifications.NotifyAsync(row.RecipientPlayerId,
|
||||||
PushNotificationId.GiftPackageReceived,
|
PushNotificationId.GiftPackageReceived,
|
||||||
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
|
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(row.Gift));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new { Success = true, Sent = created.Count });
|
return Ok(new { Success = true, Sent = created.Count });
|
||||||
|
|||||||
@@ -1295,39 +1295,14 @@ public class ImagesController(
|
|||||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||||
.Where(v => v > 0);
|
.Where(v => v > 0);
|
||||||
|
|
||||||
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
|
/// <summary>Push the new profile image through the same account pushes
|
||||||
{
|
/// as every other profile edit: the owner's SelfAccountUpdate and an
|
||||||
var account = new DorkNet.Models.Auth.RecNetAccount
|
/// AccountUpdate to everyone. A hand-built account here left out the
|
||||||
{
|
/// emoji, banner, pronouns, identity flags and creation date, so an image
|
||||||
AccountId = (int)player.Id,
|
/// change wiped them from the owner's cached account, and nobody else saw
|
||||||
RawUsername = player.Username,
|
/// the new picture.</summary>
|
||||||
Username = player.Username,
|
private Task NotifyAccountImageChangedAsync(PlayerEntity player) =>
|
||||||
DisplayName = player.DisplayName ?? player.Username,
|
notifications.AccountChanged(player);
|
||||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
|
||||||
TreatAsJunior = player.IsJunior,
|
|
||||||
HasBirthday = true,
|
|
||||||
Platforms = 1,
|
|
||||||
};
|
|
||||||
var selfAccount = new DorkNet.Models.Auth.RecNetSelfAccount
|
|
||||||
{
|
|
||||||
AccountId = account.AccountId,
|
|
||||||
RawUsername = account.RawUsername,
|
|
||||||
Username = account.Username,
|
|
||||||
DisplayName = account.DisplayName,
|
|
||||||
ProfileImage = account.ProfileImage,
|
|
||||||
TreatAsJunior = account.TreatAsJunior,
|
|
||||||
HasBirthday = account.HasBirthday,
|
|
||||||
Platforms = account.Platforms,
|
|
||||||
Email = player.Email ?? string.Empty,
|
|
||||||
Phone = player.Phone ?? string.Empty,
|
|
||||||
Birthday = player.Birthday,
|
|
||||||
JuniorState = player.IsJunior ? 1 : 0,
|
|
||||||
ParentAccountId = null,
|
|
||||||
};
|
|
||||||
return Task.WhenAll(
|
|
||||||
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
|
|
||||||
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormaliseTaggedIds(string? csv, long exceptId)
|
private static string NormaliseTaggedIds(string? csv, long exceptId)
|
||||||
{
|
{
|
||||||
|
|||||||
+52
@@ -34,6 +34,58 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The caller's referred players, paged.
|
||||||
|
///
|
||||||
|
/// <c>FGLDKEJLAKB<GPJIMAHMAIO> EGEEFJHAGAM(Int32? take, String continuationToken)</c>
|
||||||
|
/// (RecNet.Runtime/LINKIECPDML.txt:337) builds this through the URL builder
|
||||||
|
/// LPPEFJMCJFN ("api/incentivizedreferrals/" + "referrals", :677-701, with
|
||||||
|
/// <c>take</c> and <c>continuationToken</c> query params at :716/:737) — which
|
||||||
|
/// is why the plain route-literal sweep never saw it — and issues a GET
|
||||||
|
/// (:164-170). Reply formatter KOEEACLMAFE.txt:203-238:
|
||||||
|
/// <c>{Referrals: [...], ContinuationToken: string}</c>; each entry
|
||||||
|
/// (ADAFNGNADFH.txt:279-346) is <c>{InviteeAccountId: Int32, CreatedAt:
|
||||||
|
/// DateTime, VerifiedAt: DateTime?}</c>.
|
||||||
|
///
|
||||||
|
/// A referral is recorded when a brand-new player redeems the caller's
|
||||||
|
/// referral code (LinkController.CreditReferralAsync), and that redemption
|
||||||
|
/// is what counts it, so it is verified at the moment it is created. The
|
||||||
|
/// continuation token is the next offset; null once the list is exhausted.</summary>
|
||||||
|
[HttpGet("referrals")]
|
||||||
|
public async Task<IActionResult> Referrals([FromQuery] int? take, [FromQuery] string? continuationToken)
|
||||||
|
{
|
||||||
|
var size = Math.Clamp(take ?? 20, 1, 100);
|
||||||
|
var offset = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
var prefix = $"referral:credited:{Me}:";
|
||||||
|
|
||||||
|
var rows = await db.PlayerSettings
|
||||||
|
.Where(s => s.Key.StartsWith(prefix))
|
||||||
|
.OrderByDescending(s => s.Id)
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(size + 1)
|
||||||
|
.Select(s => new { s.PlayerId, s.Value })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var page = rows.Take(size).Select(r =>
|
||||||
|
{
|
||||||
|
var at = DateTime.TryParse(r.Value, null,
|
||||||
|
System.Globalization.DateTimeStyles.RoundtripKind, out var credited)
|
||||||
|
? credited
|
||||||
|
: DateTime.UnixEpoch;
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
InviteeAccountId = (int)r.PlayerId,
|
||||||
|
CreatedAt = at,
|
||||||
|
VerifiedAt = (DateTime?)at,
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Referrals = page,
|
||||||
|
ContinuationToken = rows.Count > size ? (offset + size).ToString() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Client contract: the claim response deserializes as a top-level
|
// Client contract: the claim response deserializes as a top-level
|
||||||
// List<RewardSelection> where each entry is
|
// List<RewardSelection> where each entry is
|
||||||
// { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
|
// { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
|
||||||
|
|||||||
@@ -1095,11 +1095,9 @@ public class InventionsController(
|
|||||||
});
|
});
|
||||||
inv.CheerCount += 1;
|
inv.CheerCount += 1;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
// No push: InventionModerationStateChanged ("100") means "your
|
||||||
if (inv.CreatorPlayerId != pid)
|
// inventions' moderation state changed" and takes no payload; sent
|
||||||
await notifications.NotifyAsync(inv.CreatorPlayerId,
|
// on every cheer it only made the creator refetch for nothing.
|
||||||
PushNotificationId.InventionModerationStateChanged,
|
|
||||||
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
|
|
||||||
}
|
}
|
||||||
else if (!req.Cheer && existing is not null)
|
else if (!req.Cheer && existing is not null)
|
||||||
{
|
{
|
||||||
@@ -1256,8 +1254,10 @@ public class InventionsController(
|
|||||||
if (!canPush) return Forbid();
|
if (!canPush) return Forbid();
|
||||||
|
|
||||||
var subRoomId = req.SubRoomId;
|
var subRoomId = req.SubRoomId;
|
||||||
|
// Wire SubRoomId (OrderIndex + 1) → the scene's OrderIndex.
|
||||||
|
var sceneOrderIndex = Services.RoomSceneIds.ToOrderIndex(subRoomId);
|
||||||
var scene = await db.RoomScenes
|
var scene = await db.RoomScenes
|
||||||
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
|
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == sceneOrderIndex);
|
||||||
|
|
||||||
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
|
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
|
||||||
if (subRoomDataFilename.Length == 0)
|
if (subRoomDataFilename.Length == 0)
|
||||||
@@ -1271,7 +1271,7 @@ public class InventionsController(
|
|||||||
if (subRoomBlob is null)
|
if (subRoomBlob is null)
|
||||||
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
|
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
|
||||||
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
|
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
|
||||||
subRoomBlob.SubRoomId ??= subRoomId;
|
subRoomBlob.SubRoomId ??= sceneOrderIndex;
|
||||||
|
|
||||||
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
|
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
|
||||||
if (roomDataFilename.Length > 0)
|
if (roomDataFilename.Length > 0)
|
||||||
|
|||||||
@@ -279,11 +279,11 @@ public class InventoryController(
|
|||||||
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
|
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// A consume always changes an existing mapping, so it is always
|
||||||
|
// ConsumableMappingRemoved (71) carrying the count that is left;
|
||||||
|
// Added (70) would make the client treat it as a new stack.
|
||||||
await notifications.NotifyAsync(pid,
|
await notifications.NotifyAsync(pid,
|
||||||
row.Quantity > 0
|
PushNotificationId.ConsumableMappingRemoved, ConsumableMappingWire(row));
|
||||||
? PushNotificationId.ConsumableMappingAdded
|
|
||||||
: PushNotificationId.ConsumableMappingRemoved,
|
|
||||||
new { row.Id, row.ItemSlug, row.Quantity });
|
|
||||||
return Ok(new { row.Id, Count = row.Quantity });
|
return Ok(new { row.Id, Count = row.Quantity });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,8 +332,7 @@ public class InventoryController(
|
|||||||
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
|
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await notifications.NotifyAsync(recipient,
|
await notifications.NotifyAsync(recipient,
|
||||||
PushNotificationId.ConsumableMappingAdded,
|
PushNotificationId.ConsumableMappingAdded, ConsumableMappingWire(target));
|
||||||
new { target.Id, target.ItemSlug, target.Quantity });
|
|
||||||
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
|
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +448,26 @@ public class InventoryController(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>ConsumableMapping push payload ("70" Added / "71" Removed):
|
||||||
|
/// exactly the client's mapping DTO <c>CGCALDBHLGD</c> (formatter
|
||||||
|
/// <c>JICIOMDBPDO</c>) — Id (Int64), ConsumableItemDesc (STRING, not an
|
||||||
|
/// object), Count, InitialCount, CreatedAt, ActiveDurationMinutes (Int32?),
|
||||||
|
/// IsActive, IsTransferable. The old <c>{Id, ItemSlug, Quantity}</c> parsed
|
||||||
|
/// as a mapping with no item and a count of 0.</summary>
|
||||||
|
public static object ConsumableMappingWire(PlayerInventoryEntity inv) => new
|
||||||
|
{
|
||||||
|
inv.Id,
|
||||||
|
ConsumableItemDesc = StoreService.TryGetConsumableItemDesc(inv.ItemSlug, out var desc) ? desc : inv.ItemSlug,
|
||||||
|
Count = inv.Quantity,
|
||||||
|
InitialCount = inv.Quantity, // the original purchase count isn't tracked
|
||||||
|
CreatedAt = inv.AcquiredAt,
|
||||||
|
ActiveDurationMinutes = (int?)null,
|
||||||
|
inv.IsActive,
|
||||||
|
// Same as GET api/consumables/v2/getUnlocked, so a push never flips
|
||||||
|
// what the inventory list says.
|
||||||
|
IsTransferable = false,
|
||||||
|
};
|
||||||
|
|
||||||
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
|
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
|
||||||
{
|
{
|
||||||
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
|
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
|
||||||
|
|||||||
@@ -208,8 +208,10 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
|||||||
/// <c>DELETE api/keepsakes/{guid}</c> - <c>NCCLEJPIABA.JBKENCNIEPA(System.Guid)</c> builds
|
/// <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
|
/// <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
|
/// 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
|
/// the non-generic <c>LDGADANDBIO</c> promise, but that promise comes from
|
||||||
/// is enough. Client-side the tool refuses unless the caller is at least a room co-owner
|
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult) — the body IS parsed, as a
|
||||||
|
/// <c>{success,error}</c> RecNetResult, and an empty 200 null-derefs into a failed delete.
|
||||||
|
/// See <see cref="RecNetResults"/>. Client-side the tool refuses unless the caller is at least a room co-owner
|
||||||
/// ("Must be at least a room co-owner to modify keepsakes.",
|
/// ("Must be at least a room co-owner to modify keepsakes.",
|
||||||
/// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side.
|
/// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -235,7 +237,7 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
|||||||
db.Keepsakes.RemoveRange(orphanedCollections
|
db.Keepsakes.RemoveRange(orphanedCollections
|
||||||
.Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId));
|
.Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId));
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -152,8 +152,6 @@ public class InGameModerationController(
|
|||||||
foreach (var target in ids)
|
foreach (var target in ids)
|
||||||
{
|
{
|
||||||
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
|
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
|
||||||
// Push the ModerationKick shape the 2020.12 client expects:
|
|
||||||
// Msg.Reason must be a ModerationBlockDetail object.
|
|
||||||
await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
|
await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
|
||||||
}
|
}
|
||||||
return Ok(new { success = true, error = "" });
|
return Ok(new { success = true, error = "" });
|
||||||
@@ -249,21 +247,14 @@ public class InGameModerationController(
|
|||||||
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
|
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
|
||||||
caller, target, reason, p.BannedUntil, displayReason);
|
caller, target, reason, p.BannedUntil, displayReason);
|
||||||
|
|
||||||
// Push the ban as a ModerationKick (22) with IsBan set, NOT as
|
// Sign the banned player out with Logout "6" (Reason Banned). Not
|
||||||
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager
|
// ModerationRoomBan (24): the 2023-03-21 client registers no handler
|
||||||
// registers only 22, "ModerationUnkick" and 23
|
// for it (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335 register only
|
||||||
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler
|
// 22, "ModerationUnkick" and 23), so that push landed nowhere. Not a
|
||||||
// for 24, so that push landed nowhere and the banned player kept playing
|
// ModerationKick either: a kick only sends the player to their dorm
|
||||||
// until their next login. The 22 handler reads Msg.Reason as a
|
// and they kept playing until their next login. The ban check refuses
|
||||||
// ModerationBlockDetail, which ModerationKickPayload already produces.
|
// that next login.
|
||||||
await notifications.KickPlayerAsync(
|
await notifications.LogoutAsync(target, NotificationService.LogoutReason.Banned);
|
||||||
target,
|
|
||||||
// `reason` is the numeric report category; DisplayReason is the
|
|
||||||
// moderator's text and is what the ban dialog shows.
|
|
||||||
string.IsNullOrWhiteSpace(displayReason)
|
|
||||||
? "You have been banned from this room."
|
|
||||||
: displayReason.Trim(),
|
|
||||||
isBan: true);
|
|
||||||
return Ok(new { success = true, error = "" });
|
return Ok(new { success = true, error = "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
|
|||||||
public class PlayerEventsController(
|
public class PlayerEventsController(
|
||||||
DorkNetDbContext db,
|
DorkNetDbContext db,
|
||||||
NotificationService notifications,
|
NotificationService notifications,
|
||||||
|
SystemNotificationService systemNotifications,
|
||||||
PlayerPresenceService presence) : ControllerBase
|
PlayerPresenceService presence) : ControllerBase
|
||||||
{
|
{
|
||||||
private long Me => this.RequireCurrentPlayerId();
|
private long Me => this.RequireCurrentPlayerId();
|
||||||
@@ -393,7 +394,11 @@ public class PlayerEventsController(
|
|||||||
if (evt is null) return NotFound();
|
if (evt is null) return NotFound();
|
||||||
|
|
||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
|
// The event stores the client's wire SubRoomId; presence and private
|
||||||
|
// instances hold the OrderIndex (RoomSceneIds).
|
||||||
|
var subRoomId = extras.SubRoomId is long sub && sub > 0
|
||||||
|
? RoomSceneIds.ToOrderIndex(sub)
|
||||||
|
: (long?)null;
|
||||||
|
|
||||||
// Same sourcing as the room browser: presence rows are the live truth
|
// Same sourcing as the room browser: presence rows are the live truth
|
||||||
// (one instance per distinct PhotonRoomId), and the caller's OWN private
|
// (one instance per distinct PhotonRoomId), and the caller's OWN private
|
||||||
@@ -416,7 +421,7 @@ public class PlayerEventsController(
|
|||||||
{
|
{
|
||||||
roomInstanceId = room.RoomInstanceId,
|
roomInstanceId = room.RoomInstanceId,
|
||||||
roomId = room.RoomId,
|
roomId = room.RoomId,
|
||||||
subRoomId = room.SubRoomId,
|
subRoomId = RoomSceneIds.ToWire(room.SubRoomId),
|
||||||
// MaxCapacity is the per-instance cap the matchmaker handed
|
// MaxCapacity is the per-instance cap the matchmaker handed
|
||||||
// out, so this is a real answer rather than a constant false.
|
// out, so this is a real answer rather than a constant false.
|
||||||
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
|
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
|
||||||
@@ -437,7 +442,7 @@ public class PlayerEventsController(
|
|||||||
{
|
{
|
||||||
roomInstanceId = p.Id,
|
roomInstanceId = p.Id,
|
||||||
roomId = p.RoomId,
|
roomId = p.RoomId,
|
||||||
subRoomId = p.SubRoomId,
|
subRoomId = RoomSceneIds.ToWire(p.SubRoomId),
|
||||||
isFull = false,
|
isFull = false,
|
||||||
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
|
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
|
||||||
playerIds = Array.Empty<int>(),
|
playerIds = Array.Empty<int>(),
|
||||||
@@ -817,7 +822,7 @@ public class PlayerEventsController(
|
|||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
await ApplyBodyToExtrasAsync(extras);
|
await ApplyBodyToExtrasAsync(extras);
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,10 +906,7 @@ public class PlayerEventsController(
|
|||||||
|
|
||||||
// Tell the creator someone RSVP'd. They can ignore but the
|
// Tell the creator someone RSVP'd. They can ignore but the
|
||||||
// watch will refresh their event detail card.
|
// watch will refresh their event detail card.
|
||||||
if (ev.CreatorPlayerId != Me)
|
await NotifyResponseChangedAsync(ev, row);
|
||||||
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
|
// Left as the historical shape on purpose: this route is 2020-only
|
||||||
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
|
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
|
||||||
// so there is no 2023 DTO to match here.
|
// so there is no 2023 DTO to match here.
|
||||||
@@ -947,10 +949,15 @@ public class PlayerEventsController(
|
|||||||
var existing = await db.PlayerEventResponses
|
var existing = await db.PlayerEventResponses
|
||||||
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
|
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response });
|
{
|
||||||
|
existing = new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response };
|
||||||
|
db.PlayerEventResponses.Add(existing);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
existing.Response = response;
|
existing.Response = response;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
var responded = await db.PlayerEvents.FirstAsync(e => e.Id == evt);
|
||||||
|
await NotifyResponseChangedAsync(responded, existing);
|
||||||
return RespondResult(legacyForm, 0 /*Success*/);
|
return RespondResult(legacyForm, 0 /*Success*/);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,9 +975,18 @@ public class PlayerEventsController(
|
|||||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||||
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||||
|
|
||||||
await db.PlayerEventResponses
|
var deleted = await db.PlayerEventResponses
|
||||||
.Where(r => r.PlayerId == Me && r.EventId == evt)
|
.Where(r => r.PlayerId == Me && r.EventId == evt)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
if (deleted > 0)
|
||||||
|
{
|
||||||
|
// PlayerEventResponseDeleted "84" carries only PlayerEventId.
|
||||||
|
var creator = await db.PlayerEvents.Where(e => e.Id == evt)
|
||||||
|
.Select(e => (long?)e.CreatorPlayerId).FirstOrDefaultAsync();
|
||||||
|
if (creator is long c && c != Me)
|
||||||
|
await notifications.NotifyAsync(c, PushNotificationId.PlayerEventResponseDeleted,
|
||||||
|
new { PlayerEventId = evt });
|
||||||
|
}
|
||||||
return RespondResult(legacyForm, 0 /*Success*/);
|
return RespondResult(legacyForm, 0 /*Success*/);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,6 +1009,7 @@ public class PlayerEventsController(
|
|||||||
// has to echo the event that was deleted.
|
// has to echo the event that was deleted.
|
||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
|
||||||
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
|
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
|
||||||
|
var notify = await AttendeeIdsAsync(evt);
|
||||||
|
|
||||||
db.PlayerEvents.Remove(evt);
|
db.PlayerEvents.Remove(evt);
|
||||||
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
|
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
|
||||||
@@ -1000,6 +1017,7 @@ public class PlayerEventsController(
|
|||||||
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
|
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyEventDeletedAsync(id, notify);
|
||||||
return Ok(Wrapper(evt, extras, attendees));
|
return Ok(Wrapper(evt, extras, attendees));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,6 +1039,7 @@ public class PlayerEventsController(
|
|||||||
evt.Title = Clamp(value.Trim(), 128);
|
evt.Title = Clamp(value.Trim(), 128);
|
||||||
evt.UpdatedAt = DateTime.UtcNow;
|
evt.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,6 +1053,7 @@ public class PlayerEventsController(
|
|||||||
evt.Description = Clamp(value.Trim(), 2000);
|
evt.Description = Clamp(value.Trim(), 2000);
|
||||||
evt.UpdatedAt = DateTime.UtcNow;
|
evt.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,6 +1076,7 @@ public class PlayerEventsController(
|
|||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
|
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,6 +1093,7 @@ public class PlayerEventsController(
|
|||||||
if (start is null && end is null) return BadRequest("missing_time");
|
if (start is null && end is null) return BadRequest("missing_time");
|
||||||
evt.UpdatedAt = DateTime.UtcNow;
|
evt.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1085,6 +1107,7 @@ public class PlayerEventsController(
|
|||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
extras.ImageName = Clamp(value.Trim(), 128);
|
extras.ImageName = Clamp(value.Trim(), 128);
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1099,6 +1122,7 @@ public class PlayerEventsController(
|
|||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
extras.Accessibility = accessibility;
|
extras.Accessibility = accessibility;
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,6 +1144,7 @@ public class PlayerEventsController(
|
|||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
extras.Tags = tags;
|
extras.Tags = tags;
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,12 +1170,20 @@ public class PlayerEventsController(
|
|||||||
extras.SupportMultiInstanceRoomChat =
|
extras.SupportMultiInstanceRoomChat =
|
||||||
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
||||||
"SupportMultiInstanceRoomChat") ?? false;
|
"SupportMultiInstanceRoomChat") ?? false;
|
||||||
extras.DefaultBroadcastPermissions =
|
// These two are boxed enums (typeof(AKFCIMLMAKA) at
|
||||||
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0;
|
// CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:117-165), and a boxed non-string
|
||||||
extras.CanRequestBroadcastPermissions =
|
// field goes through Object.ToString (BNDIAONDFFF.txt:947, ISIL 107-110),
|
||||||
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0;
|
// so they arrive as member NAMES. int.TryParse failed on every one and
|
||||||
|
// the settings were always stored as 0.
|
||||||
|
extras.DefaultBroadcastPermissions = ParseBroadcastPermissions(
|
||||||
|
await ReadStringFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions"))
|
||||||
|
?? extras.DefaultBroadcastPermissions;
|
||||||
|
extras.CanRequestBroadcastPermissions = ParseBroadcastPermissions(
|
||||||
|
await ReadStringFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions"))
|
||||||
|
?? extras.CanRequestBroadcastPermissions;
|
||||||
|
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1181,6 +1214,7 @@ public class PlayerEventsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,6 +1224,7 @@ public class PlayerEventsController(
|
|||||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||||
if (ev is null) return NotFound();
|
if (ev is null) return NotFound();
|
||||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||||
|
var notify = await AttendeeIdsAsync(ev);
|
||||||
db.PlayerEvents.Remove(ev);
|
db.PlayerEvents.Remove(ev);
|
||||||
// Cascade-clean the responses; SQLite has no FK cascade
|
// Cascade-clean the responses; SQLite has no FK cascade
|
||||||
// configured here so do it manually.
|
// configured here so do it manually.
|
||||||
@@ -1199,6 +1234,7 @@ public class PlayerEventsController(
|
|||||||
await db.PlayerSettings
|
await db.PlayerSettings
|
||||||
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
|
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
await NotifyEventDeletedAsync(eventId, notify);
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,6 +1273,7 @@ public class PlayerEventsController(
|
|||||||
var failed = new List<object>();
|
var failed = new List<object>();
|
||||||
var sender = Me;
|
var sender = Me;
|
||||||
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
|
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
|
||||||
|
var eventWire = await ToWireOneAsync(ev);
|
||||||
foreach (var rid in req.InvitedPlayerIds.Distinct())
|
foreach (var rid in req.InvitedPlayerIds.Distinct())
|
||||||
{
|
{
|
||||||
var exists = await db.Players.AnyAsync(p => p.Id == rid);
|
var exists = await db.Players.AnyAsync(p => p.Id == rid);
|
||||||
@@ -1245,17 +1282,15 @@ public class PlayerEventsController(
|
|||||||
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
|
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
db.Messages.Add(new MessageEntity
|
// The invite as a text message from the host, stored and pushed
|
||||||
{
|
// live (it used to be stored only, so it appeared after a relog).
|
||||||
SenderPlayerId = sender,
|
await systemNotifications.SendAsync(rid,
|
||||||
RecipientPlayerId = rid,
|
SystemNotificationService.MessageType.TextMessage, fromPlayerId: sender, data: inviteText);
|
||||||
Body = inviteText,
|
// And the event itself, so it shows in the invitee's event list:
|
||||||
});
|
// PlayerEventCreated "80" carries the full event (MDCBEPJCJPO) —
|
||||||
await notifications.NotifyAsync(rid,
|
// the same object GET api/playerevents/v2/{id} returns.
|
||||||
PushNotificationId.PlayerEventCreated,
|
await notifications.NotifyAsync(rid, PushNotificationId.PlayerEventCreated, eventWire);
|
||||||
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
|
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1282,21 +1317,56 @@ public class PlayerEventsController(
|
|||||||
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
|
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
|
||||||
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
|
// Attendees' event cards key off BroadcastingRoomInstanceId, so send
|
||||||
// them to re-fetch instead of sending a chat message.
|
// them the updated event.
|
||||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
|
/// <summary>Everyone who answered the event with anything but NotGoing,
|
||||||
{
|
/// except its creator.</summary>
|
||||||
var recipients = await db.PlayerEventResponses
|
private async Task<List<long>> AttendeeIdsAsync(PlayerEventEntity evt) =>
|
||||||
|
(await db.PlayerEventResponses
|
||||||
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
|
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
|
||||||
.Select(r => r.PlayerId)
|
.Select(r => r.PlayerId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync();
|
.ToListAsync())
|
||||||
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
|
.Where(p => p != evt.CreatorPlayerId)
|
||||||
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>PlayerEventUpdated "81" to the attendees, carrying the full
|
||||||
|
/// event (MDCBEPJCJPO, formatter CENEMCMGDKG) — the same object
|
||||||
|
/// GET api/playerevents/v2/{id} returns; the client replaces its cached
|
||||||
|
/// event with it. The old <c>{PlayerEventId, Title}</c> overwrote the
|
||||||
|
/// cached event with an empty name and default times, and the
|
||||||
|
/// PlayerEventStateChanged (85) used for broadcasts has no client handler
|
||||||
|
/// at all.</summary>
|
||||||
|
private async Task NotifyEventUpdatedAsync(PlayerEventEntity evt)
|
||||||
|
{
|
||||||
|
var recipients = await AttendeeIdsAsync(evt);
|
||||||
|
if (recipients.Count == 0) return;
|
||||||
|
var wire = await ToWireOneAsync(evt);
|
||||||
|
foreach (var playerId in recipients)
|
||||||
|
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventUpdated, wire);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PlayerEventDeleted "82" ({PlayerEventId}) to the players who
|
||||||
|
/// were attending, collected before the rows were removed.</summary>
|
||||||
|
private async Task NotifyEventDeletedAsync(long eventId, IEnumerable<long> recipients)
|
||||||
|
{
|
||||||
|
foreach (var playerId in recipients)
|
||||||
|
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventDeleted,
|
||||||
|
new { PlayerEventId = eventId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PlayerEventResponseChanged "83" to the event's creator:
|
||||||
|
/// <c>{PlayerEvent, PlayerEventResponse}</c> (COAGAJPELCG), each the same
|
||||||
|
/// object the event and response REST routes return.</summary>
|
||||||
|
private async Task NotifyResponseChangedAsync(PlayerEventEntity evt, PlayerEventResponseEntity response)
|
||||||
|
{
|
||||||
|
if (evt.CreatorPlayerId == response.PlayerId) return;
|
||||||
|
await notifications.NotifyAsync(evt.CreatorPlayerId, PushNotificationId.PlayerEventResponseChanged,
|
||||||
|
new { PlayerEvent = await ToWireOneAsync(evt), PlayerEventResponse = ResponseWire(response) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Report ───────────────────────────────────────────────────────────
|
// ── Report ───────────────────────────────────────────────────────────
|
||||||
@@ -1437,6 +1507,33 @@ public class PlayerEventsController(
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Broadcast-permission mode as a number. Members per the
|
||||||
|
/// 2023.06.21 dump (enum KJJFEBEMPKM, mapped by
|
||||||
|
/// EventsConfig.BroadcastPermissionsModeFriendlyNameMapping): None = 0,
|
||||||
|
/// RoomOwners = 256, All = Int32.MaxValue. Accepts the number itself (JSON
|
||||||
|
/// bodies send numbers), a member name, or a comma-joined flags string.
|
||||||
|
/// Null when the value is absent or unrecognised, so the stored setting is
|
||||||
|
/// kept rather than silently zeroed.</summary>
|
||||||
|
public static int? ParseBroadcastPermissions(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
|
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) return n;
|
||||||
|
var total = 0;
|
||||||
|
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
int? value = part.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"none" => 0,
|
||||||
|
"roomowners" => 256,
|
||||||
|
"all" => int.MaxValue,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (value is null) return null;
|
||||||
|
total |= value.Value;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||||
{
|
{
|
||||||
var value = await ReadStringFieldAsync(names);
|
var value = await ReadStringFieldAsync(names);
|
||||||
|
|||||||
@@ -48,14 +48,18 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
|||||||
.Where(p => p.Id == me)
|
.Where(p => p.Id == me)
|
||||||
.Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam))
|
.Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam))
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
// Both calls are read as DJMHAFPGLLN = {Success, Error} (the formatter,
|
||||||
|
// RecNet.Runtime/LMCCNLLHLCJ.txt:191-218, knows no "Message" key). A
|
||||||
|
// failure without Error reached Promise.Rejected(null) and threw
|
||||||
|
// "Error message cannot be null" instead of showing the reason.
|
||||||
if (moderator != true)
|
if (moderator != true)
|
||||||
return Ok(new { Success = false, Message = "not_authorized" });
|
return Ok(new { Success = false, Error = "not_authorized", Message = "not_authorized" });
|
||||||
|
|
||||||
var target = warnedPlayerId
|
var target = warnedPlayerId
|
||||||
?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0);
|
?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0);
|
||||||
if (target <= 0) return Ok(new { Success = false, Message = "missing_player" });
|
if (target <= 0) return Ok(new { Success = false, Error = "missing_player", Message = "missing_player" });
|
||||||
if (!await db.Players.AnyAsync(p => p.Id == target))
|
if (!await db.Players.AnyAsync(p => p.Id == target))
|
||||||
return Ok(new { Success = false, Message = "player_not_found" });
|
return Ok(new { Success = false, Error = "player_not_found", Message = "player_not_found" });
|
||||||
|
|
||||||
// Same "warning:{id}|message|category|createdAt|acknowledged" layout the
|
// Same "warning:{id}|message|category|createdAt|acknowledged" layout the
|
||||||
// GET list handler parses in ToWarningWire.
|
// GET list handler parses in ToWarningWire.
|
||||||
@@ -75,7 +79,7 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
|||||||
"false"),
|
"false"),
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(new { Success = true, Message = string.Empty, WarningId = id });
|
return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = id });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The 2023-03-21 client's acknowledge call takes NO arguments —
|
/// <summary>The 2023-03-21 client's acknowledge call takes NO arguments —
|
||||||
@@ -104,19 +108,19 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
|||||||
.OrderByDescending(s => s.Id)
|
.OrderByDescending(s => s.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged);
|
row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged);
|
||||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
row = await db.PlayerSettings
|
row = await db.PlayerSettings
|
||||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
|
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
|
||||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty });
|
||||||
}
|
}
|
||||||
|
|
||||||
var parts = row.Value.Split('|');
|
var parts = row.Value.Split('|');
|
||||||
row.Value = string.Join('|', parts.Take(3).Append("true"));
|
row.Value = string.Join('|', parts.Take(3).Append("true"));
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
|
return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
|
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public class PlaylistsController(
|
|||||||
/// query or JSON body so JSON callers (admin UI) keep working.</summary>
|
/// query or JSON body so JSON callers (admin UI) keep working.</summary>
|
||||||
[HttpPost("/playlists")]
|
[HttpPost("/playlists")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> Create()
|
public async Task<IActionResult> Create()
|
||||||
{
|
{
|
||||||
var name = await ReadFieldAsync("name", "Name");
|
var name = await ReadFieldAsync("name", "Name");
|
||||||
@@ -87,6 +88,7 @@ public class PlaylistsController(
|
|||||||
[HttpPost("/playlists/{playlistId:long}/name")]
|
[HttpPost("/playlists/{playlistId:long}/name")]
|
||||||
[HttpPut("/playlists/{playlistId:long}/name")]
|
[HttpPut("/playlists/{playlistId:long}/name")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> PlaylistName(long playlistId)
|
public async Task<IActionResult> PlaylistName(long playlistId)
|
||||||
{
|
{
|
||||||
var value = await ReadFieldAsync("name", "Name", "value", "Value");
|
var value = await ReadFieldAsync("name", "Name", "value", "Value");
|
||||||
@@ -96,6 +98,7 @@ public class PlaylistsController(
|
|||||||
[HttpPost("/playlists/{playlistId:long}/description")]
|
[HttpPost("/playlists/{playlistId:long}/description")]
|
||||||
[HttpPut("/playlists/{playlistId:long}/description")]
|
[HttpPut("/playlists/{playlistId:long}/description")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> PlaylistDescription(long playlistId)
|
public async Task<IActionResult> PlaylistDescription(long playlistId)
|
||||||
{
|
{
|
||||||
var value = await ReadFieldAsync("description", "Description", "value", "Value");
|
var value = await ReadFieldAsync("description", "Description", "value", "Value");
|
||||||
@@ -105,6 +108,7 @@ public class PlaylistsController(
|
|||||||
[HttpPost("/playlists/{playlistId:long}/image")]
|
[HttpPost("/playlists/{playlistId:long}/image")]
|
||||||
[HttpPut("/playlists/{playlistId:long}/image")]
|
[HttpPut("/playlists/{playlistId:long}/image")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> PlaylistImage(long playlistId)
|
public async Task<IActionResult> PlaylistImage(long playlistId)
|
||||||
{
|
{
|
||||||
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
|
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
|
||||||
@@ -116,6 +120,7 @@ public class PlaylistsController(
|
|||||||
[HttpPost("/playlists/{playlistId:long}/tags")]
|
[HttpPost("/playlists/{playlistId:long}/tags")]
|
||||||
[HttpPut("/playlists/{playlistId:long}/tags")]
|
[HttpPut("/playlists/{playlistId:long}/tags")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> PlaylistTags(long playlistId)
|
public async Task<IActionResult> PlaylistTags(long playlistId)
|
||||||
{
|
{
|
||||||
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
|
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
|
||||||
@@ -131,6 +136,7 @@ public class PlaylistsController(
|
|||||||
[HttpPost("/playlists/{playlistId:long}/visibility")]
|
[HttpPost("/playlists/{playlistId:long}/visibility")]
|
||||||
[HttpPut("/playlists/{playlistId:long}/visibility")]
|
[HttpPut("/playlists/{playlistId:long}/visibility")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
|
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
|
||||||
{
|
{
|
||||||
var value = await ReadFieldAsync(
|
var value = await ReadFieldAsync(
|
||||||
@@ -144,6 +150,7 @@ public class PlaylistsController(
|
|||||||
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
|
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
|
||||||
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
|
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
|
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
|
||||||
{
|
{
|
||||||
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
|
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
|
||||||
@@ -156,6 +163,7 @@ public class PlaylistsController(
|
|||||||
[HttpPost("/playlists/{playlistId:long}/restrictions")]
|
[HttpPost("/playlists/{playlistId:long}/restrictions")]
|
||||||
[HttpPut("/playlists/{playlistId:long}/restrictions")]
|
[HttpPut("/playlists/{playlistId:long}/restrictions")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
|
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
|
||||||
{
|
{
|
||||||
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
|
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
|
||||||
@@ -174,6 +182,7 @@ public class PlaylistsController(
|
|||||||
[HttpPost("/playlists/{playlistId:long}/warning")]
|
[HttpPost("/playlists/{playlistId:long}/warning")]
|
||||||
[HttpPut("/playlists/{playlistId:long}/warning")]
|
[HttpPut("/playlists/{playlistId:long}/warning")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> PlaylistWarning(long playlistId)
|
public async Task<IActionResult> PlaylistWarning(long playlistId)
|
||||||
{
|
{
|
||||||
var mask = await ReadFieldAsync("warningMask", "WarningMask");
|
var mask = await ReadFieldAsync("warningMask", "WarningMask");
|
||||||
@@ -295,6 +304,7 @@ public class PlaylistsController(
|
|||||||
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||||
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
|
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -307,6 +317,7 @@ public class PlaylistsController(
|
|||||||
|
|
||||||
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
|
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase
|
public class RelationshipsController(
|
||||||
|
DorkNetDbContext db,
|
||||||
|
NotificationService notifications,
|
||||||
|
SystemNotificationService systemNotifications,
|
||||||
|
ServerSettingsService settings) : ControllerBase
|
||||||
{
|
{
|
||||||
// RelationshipType — int values match Relationship.RelationshipType
|
// RelationshipType — int values match Relationship.RelationshipType
|
||||||
// in the patched dump.cs.
|
// in the patched dump.cs.
|
||||||
@@ -226,7 +230,9 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await notifications.FriendRequestReceived(id, me);
|
await PushPairAsync(me, id);
|
||||||
|
// The "X sent you a friend request" row in the target's notifications.
|
||||||
|
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendInvite, fromPlayerId: me);
|
||||||
return Ok(BuildRelationship(row, me));
|
return Ok(BuildRelationship(row, me));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +257,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
rel.UpdatedAt = DateTime.UtcNow;
|
rel.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await notifications.FriendRequestAccepted(id, me);
|
await PushPairAsync(me, id);
|
||||||
|
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
|
||||||
return Ok(BuildRelationship(rel, me));
|
return Ok(BuildRelationship(rel, me));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +292,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
|
|
||||||
db.Relationships.RemoveRange(removable);
|
db.Relationships.RemoveRange(removable);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await notifications.FriendRemoved(id, me);
|
await PushPairAsync(me, id);
|
||||||
|
|
||||||
var remaining = pair.Except(removable).ToList();
|
var remaining = pair.Except(removable).ToList();
|
||||||
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
|
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
|
||||||
@@ -333,6 +340,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
existing.UpdatedAt = DateTime.UtcNow;
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await PushPairAsync(me, id);
|
||||||
|
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
|
||||||
return Ok(BuildRelationship(existing, me));
|
return Ok(BuildRelationship(existing, me));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +420,10 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
if (ignored is bool i) row.Ignored = i;
|
if (ignored is bool i) row.Ignored = i;
|
||||||
row.UpdatedAt = DateTime.UtcNow;
|
row.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
// The HTTP reply is only an ack; the updated row reaches the client
|
||||||
|
// through RelationshipChanged. A preference is the caller's own bit,
|
||||||
|
// so only the caller is told.
|
||||||
|
await PushPairAsync(me, otherId, notifyOther: false);
|
||||||
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
|
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +524,36 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Push RelationshipChanged for the pair (<paramref name="me"/>,
|
||||||
|
/// <paramref name="other"/>) after a change, each side getting its OWN view
|
||||||
|
/// of the relationship. Without this a friend request, accept or unfriend
|
||||||
|
/// only showed up after the friends list was refetched, and the client
|
||||||
|
/// never re-subscribed to the new friend's presence.</summary>
|
||||||
|
private async Task PushPairAsync(long me, long other, bool notifyOther = true)
|
||||||
|
{
|
||||||
|
var pair = await db.Relationships
|
||||||
|
.Where(r => (r.RequesterId == me && r.TargetId == other) ||
|
||||||
|
(r.RequesterId == other && r.TargetId == me))
|
||||||
|
.ToListAsync();
|
||||||
|
var globalFriends = await settings.IsGlobalFriendsEnabledAsync();
|
||||||
|
|
||||||
|
await notifications.RelationshipChanged(me, ViewOf(me, other, pair, globalFriends));
|
||||||
|
if (notifyOther)
|
||||||
|
await notifications.RelationshipChanged(other, ViewOf(other, me, pair, globalFriends));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The relationship from <paramref name="viewer"/>'s side, the way
|
||||||
|
/// <c>v2/get</c> would list it.</summary>
|
||||||
|
private static Dictionary<string, object> ViewOf(
|
||||||
|
long viewer, long other, List<RelationshipEntity> pair, bool globalFriends)
|
||||||
|
{
|
||||||
|
if (globalFriends
|
||||||
|
&& other != RelationshipQueries.SystemAccountId
|
||||||
|
&& !pair.Any(r => r.Status == EntityStatus.Blocked))
|
||||||
|
return SynthFriend(other, pair.FirstOrDefault(r => r.RequesterId == viewer), viewer);
|
||||||
|
return pair.Count == 0 ? EmptyRelationship(other) : BuildMerged(viewer, other, pair);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
|
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
|
||||||
{
|
{
|
||||||
var rows = await db.Relationships
|
var rows = await db.Relationships
|
||||||
|
|||||||
@@ -284,7 +284,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
|||||||
offer.IsDeleted = true;
|
offer.IsDeleted = true;
|
||||||
offer.UpdatedAt = DateTime.UtcNow;
|
offer.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(ToOfferWire(offer, currency));
|
// MIHKAJFMIPE.OHCONDGELJB hands this body to BFDGEKKNAPB.PEFKBAEAOPD
|
||||||
|
// (ExpandRecNetResult): it is a {success,error} RecNetResult, not the
|
||||||
|
// offer. The offer wire has no Success key, so it parsed as a failure
|
||||||
|
// with a null Error and the delete toast threw instead of closing.
|
||||||
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Both verbs are mapped on purpose: the client picks GET or POST at
|
/// <summary>Both verbs are mapped on purpose: the client picks GET or POST at
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public class RoomsController(
|
|||||||
DomainConfig domain,
|
DomainConfig domain,
|
||||||
NotificationService notifications,
|
NotificationService notifications,
|
||||||
ServerSettingsService serverSettings,
|
ServerSettingsService serverSettings,
|
||||||
|
SystemNotificationService systemNotifications,
|
||||||
ILogger<RoomsController> logger) : ControllerBase
|
ILogger<RoomsController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
|
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
|
||||||
@@ -574,6 +575,7 @@ public class RoomsController(
|
|||||||
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
|
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
|
||||||
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
|
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var blobs = await db.RoomDataBlobs
|
var blobs = await db.RoomDataBlobs
|
||||||
.Where(b => b.RoomId == roomId
|
.Where(b => b.RoomId == roomId
|
||||||
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
|
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
|
||||||
@@ -586,7 +588,7 @@ public class RoomsController(
|
|||||||
.Take(50)
|
.Take(50)
|
||||||
.Select(b => new
|
.Select(b => new
|
||||||
{
|
{
|
||||||
SubRoomId = subRoomId,
|
SubRoomId = RoomSceneIds.ToWire(subRoomId),
|
||||||
DataBlob = b.BlobName,
|
DataBlob = b.BlobName,
|
||||||
SavedByAccountId = b.UploadedByPlayerId,
|
SavedByAccountId = b.UploadedByPlayerId,
|
||||||
CreatedAt = b.UploadedAt,
|
CreatedAt = b.UploadedAt,
|
||||||
@@ -613,6 +615,7 @@ public class RoomsController(
|
|||||||
[FromQuery] int? unityAssetTarget,
|
[FromQuery] int? unityAssetTarget,
|
||||||
[FromQuery] int? unityAssetVersion)
|
[FromQuery] int? unityAssetVersion)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var room = await rooms.GetByIdAsync(roomId);
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||||
|
|
||||||
@@ -661,6 +664,7 @@ public class RoomsController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
|
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var room = await rooms.GetByIdAsync(roomId);
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||||
|
|
||||||
@@ -689,6 +693,7 @@ public class RoomsController(
|
|||||||
[FromQuery] int? unityAssetTarget,
|
[FromQuery] int? unityAssetTarget,
|
||||||
[FromQuery] int? unityAssetVersion)
|
[FromQuery] int? unityAssetVersion)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var room = await rooms.GetByIdAsync(roomId);
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||||
|
|
||||||
@@ -731,6 +736,7 @@ public class RoomsController(
|
|||||||
long roomId, long subRoomId,
|
long roomId, long subRoomId,
|
||||||
[FromQuery] int skip = 0, [FromQuery] int take = 50)
|
[FromQuery] int skip = 0, [FromQuery] int take = 50)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var room = await rooms.GetByIdAsync(roomId);
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||||
|
|
||||||
@@ -763,7 +769,7 @@ public class RoomsController(
|
|||||||
saves.Add(new
|
saves.Add(new
|
||||||
{
|
{
|
||||||
SubRoomDataSaveId = currentSaveId,
|
SubRoomDataSaveId = currentSaveId,
|
||||||
SubRoomId = subRoomId,
|
SubRoomId = RoomSceneIds.ToWire(subRoomId),
|
||||||
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||||
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||||
@@ -809,7 +815,7 @@ public class RoomsController(
|
|||||||
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
|
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
SubRoomId = (long)orderIndex,
|
SubRoomId = RoomSceneIds.ToWire(orderIndex),
|
||||||
RoomId = room.Id,
|
RoomId = room.Id,
|
||||||
Name = scene?.Name ?? "Home",
|
Name = scene?.Name ?? "Home",
|
||||||
DataBlob = dataBlob,
|
DataBlob = dataBlob,
|
||||||
@@ -824,7 +830,7 @@ public class RoomsController(
|
|||||||
CurrentSave = new
|
CurrentSave = new
|
||||||
{
|
{
|
||||||
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
|
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
|
||||||
SubRoomId = (long)orderIndex,
|
SubRoomId = RoomSceneIds.ToWire(orderIndex),
|
||||||
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||||
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||||
@@ -871,7 +877,7 @@ public class RoomsController(
|
|||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
SubRoomDataSaveId = saveId,
|
SubRoomDataSaveId = saveId,
|
||||||
SubRoomId = subRoomId,
|
SubRoomId = RoomSceneIds.ToWire(subRoomId),
|
||||||
UnityAssetId = unityAssetId,
|
UnityAssetId = unityAssetId,
|
||||||
SubRoomUnityAssetId = unityAssetId,
|
SubRoomUnityAssetId = unityAssetId,
|
||||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||||
@@ -916,13 +922,14 @@ public class RoomsController(
|
|||||||
public async Task<IActionResult> SubRoomRestoreData(
|
public async Task<IActionResult> SubRoomRestoreData(
|
||||||
long roomId, long subRoomId, [FromForm] string? filename)
|
long roomId, long subRoomId, [FromForm] string? filename)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
if (string.IsNullOrWhiteSpace(filename))
|
if (string.IsNullOrWhiteSpace(filename))
|
||||||
return BadRequest(new { error = "missing_filename" });
|
return BadRequest(new { error = "missing_filename" });
|
||||||
|
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var room = await rooms.GetByIdAsync(roomId);
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
if (room is null) return NotFound(new { error = "room_not_found" });
|
if (room is null) return NotFound(new { error = "room_not_found" });
|
||||||
if (room.CreatorPlayerId != pid) return Forbid();
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||||
|
|
||||||
var blob = await db.RoomDataBlobs
|
var blob = await db.RoomDataBlobs
|
||||||
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
|
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
|
||||||
@@ -1399,6 +1406,7 @@ public class RoomsController(
|
|||||||
.ExecuteUpdateAsync(s => s
|
.ExecuteUpdateAsync(s => s
|
||||||
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
|
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
|
||||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
||||||
|
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(room.Id, blob.BlobName);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
// Push the same SubscriptionUpdateRoom the save flow sends so
|
// Push the same SubscriptionUpdateRoom the save flow sends so
|
||||||
@@ -1472,10 +1480,27 @@ public class RoomsController(
|
|||||||
.ExecuteUpdateAsync(s => s
|
.ExecuteUpdateAsync(s => s
|
||||||
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
|
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
|
||||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
||||||
|
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(roomId, blob.BlobName);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(new { restored = blob.BlobName });
|
return Ok(new { restored = blob.BlobName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A room-level restore also restores the entry sub-room
|
||||||
|
/// (OrderIndex 0): room details serve the entry scene's own blob ahead
|
||||||
|
/// of the room's, so updating only <c>Rooms.CurrentDataBlobName</c>
|
||||||
|
/// would leave the old version in place. The per-sub-room restore keeps
|
||||||
|
/// the two in step the other way round. Dorms keep their blob per
|
||||||
|
/// player (DormState) instead.</summary>
|
||||||
|
private Task SyncEntrySceneBlobAsync(long roomId, string blobName)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
return db.RoomScenes
|
||||||
|
.Where(s => s.RoomId == roomId && s.OrderIndex == 0)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(x => x.DataBlobName, blobName)
|
||||||
|
.SetProperty(x => x.DataModifiedAt, now));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
|
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
|
||||||
/// per-caller bookmark/cheer flags for one room. Wire shape verified
|
/// per-caller bookmark/cheer flags for one room. Wire shape verified
|
||||||
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
|
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
|
||||||
@@ -2001,7 +2026,7 @@ public class RoomsController(
|
|||||||
UseLevelBasedMatchmaking = false,
|
UseLevelBasedMatchmaking = false,
|
||||||
UseAgeBasedMatchmaking = false,
|
UseAgeBasedMatchmaking = false,
|
||||||
UseRecRoyaleMatchmaking = false,
|
UseRecRoyaleMatchmaking = false,
|
||||||
SubRoomId = (long)s.OrderIndex,
|
SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
|
||||||
RoomId = room.Id,
|
RoomId = room.Id,
|
||||||
Name = s.Name,
|
Name = s.Name,
|
||||||
UnityAssetId = s.StudioUnityAssetId,
|
UnityAssetId = s.StudioUnityAssetId,
|
||||||
@@ -2027,7 +2052,7 @@ public class RoomsController(
|
|||||||
UseLevelBasedMatchmaking = false,
|
UseLevelBasedMatchmaking = false,
|
||||||
UseAgeBasedMatchmaking = false,
|
UseAgeBasedMatchmaking = false,
|
||||||
UseRecRoyaleMatchmaking = false,
|
UseRecRoyaleMatchmaking = false,
|
||||||
SubRoomId = 0L,
|
SubRoomId = RoomSceneIds.ToWire(0),
|
||||||
RoomId = room.Id,
|
RoomId = room.Id,
|
||||||
Name = "Home",
|
Name = "Home",
|
||||||
UnityAssetId = string.Empty,
|
UnityAssetId = string.Empty,
|
||||||
@@ -2046,11 +2071,10 @@ public class RoomsController(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
var wireRoles = new List<object>
|
// The creator is Creator (255), never CoOwner (30): the client reads
|
||||||
{
|
// the local player's role from this list and only lets a player edit
|
||||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
|
// roles strictly below their own (see RoomRoleRules).
|
||||||
};
|
var wireRoles = RoomRoleRules.BuildWires(room, roleList);
|
||||||
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
|
|
||||||
|
|
||||||
var tags = string.IsNullOrEmpty(room.TagsCsv)
|
var tags = string.IsNullOrEmpty(room.TagsCsv)
|
||||||
? Array.Empty<object>()
|
? Array.Empty<object>()
|
||||||
@@ -2075,7 +2099,9 @@ public class RoomsController(
|
|||||||
State = room.State,
|
State = room.State,
|
||||||
Accessibility = room.Accessibility,
|
Accessibility = room.Accessibility,
|
||||||
SupportsLevelVoting = room.SupportsLevelVoting,
|
SupportsLevelVoting = room.SupportsLevelVoting,
|
||||||
IsRRO = room.IsAGRoom,
|
// Player rooms are never RRO on the wire (see RoomService.WireIsRRO):
|
||||||
|
// the client refuses publish and room settings for an RRO.
|
||||||
|
IsRRO = RoomService.WireIsRRO(room),
|
||||||
SupportsScreens = room.SupportsScreens,
|
SupportsScreens = room.SupportsScreens,
|
||||||
SupportsWalkVR = room.SupportsWalkVR,
|
SupportsWalkVR = room.SupportsWalkVR,
|
||||||
SupportsTeleportVR = room.SupportsTeleportVR,
|
SupportsTeleportVR = room.SupportsTeleportVR,
|
||||||
@@ -2904,6 +2930,7 @@ public class RoomsController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/name")]
|
[HttpPost("roomserver/rooms/{roomId:long}/name")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/name")]
|
[HttpPut("roomserver/rooms/{roomId:long}/name")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> RenameRoom(
|
public async Task<IActionResult> RenameRoom(
|
||||||
long roomId,
|
long roomId,
|
||||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
||||||
@@ -2922,9 +2949,9 @@ public class RoomsController(
|
|||||||
{
|
{
|
||||||
// RoomRoles co-owner check — same authorisation as the
|
// RoomRoles co-owner check — same authorisation as the
|
||||||
// existing modify endpoint, lets co-owners rename too.
|
// existing modify endpoint, lets co-owners rename too.
|
||||||
var coOwner = await db.RoomRoles.AnyAsync(rr =>
|
// Room settings are creator / co-owner only; moderators and
|
||||||
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
|
// hosts moderate players, they don't edit the room.
|
||||||
if (!coOwner) return Forbid();
|
if (!await CanEditRoomAsync(room, me)) return Forbid();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
|
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
|
||||||
@@ -2959,11 +2986,13 @@ public class RoomsController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> RenameSubroom(
|
public async Task<IActionResult> RenameSubroom(
|
||||||
long roomId,
|
long roomId,
|
||||||
long subRoomId,
|
long subRoomId,
|
||||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var newName = body?.Name;
|
var newName = body?.Name;
|
||||||
if (string.IsNullOrWhiteSpace(newName))
|
if (string.IsNullOrWhiteSpace(newName))
|
||||||
return BadRequest(new { Result = 1, error = "empty_name" });
|
return BadRequest(new { Result = 1, error = "empty_name" });
|
||||||
@@ -2976,9 +3005,9 @@ public class RoomsController(
|
|||||||
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
|
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
|
||||||
if (room.CreatorPlayerId != me)
|
if (room.CreatorPlayerId != me)
|
||||||
{
|
{
|
||||||
var coOwner = await db.RoomRoles.AnyAsync(rr =>
|
// Room settings are creator / co-owner only; moderators and
|
||||||
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
|
// hosts moderate players, they don't edit the room.
|
||||||
if (!coOwner) return Forbid();
|
if (!await CanEditRoomAsync(room, me)) return Forbid();
|
||||||
}
|
}
|
||||||
|
|
||||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
|
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
|
||||||
@@ -3607,6 +3636,7 @@ public class RoomsController(
|
|||||||
[HttpPost("rooms/{roomId:long}/subrooms")]
|
[HttpPost("rooms/{roomId:long}/subrooms")]
|
||||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> AddSubRoom(
|
public async Task<IActionResult> AddSubRoom(
|
||||||
long roomId,
|
long roomId,
|
||||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
|
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
|
||||||
@@ -3614,7 +3644,7 @@ public class RoomsController(
|
|||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
if (room.CreatorPlayerId != pid) return Forbid();
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||||
|
|
||||||
var name = (req.Name ?? string.Empty).Trim();
|
var name = (req.Name ?? string.Empty).Trim();
|
||||||
if (name.Length == 0) name = "NewScene";
|
if (name.Length == 0) name = "NewScene";
|
||||||
@@ -3649,12 +3679,14 @@ public class RoomsController(
|
|||||||
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||||
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
|
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
if (room.CreatorPlayerId != pid) return Forbid();
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||||
var scene = await db.RoomScenes
|
var scene = await db.RoomScenes
|
||||||
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||||
if (scene is null) return NotFound();
|
if (scene is null) return NotFound();
|
||||||
@@ -3710,20 +3742,6 @@ public class RoomsController(
|
|||||||
/// <summary>One row of the per-subroom role-permission matrix the 2023
|
/// <summary>One row of the per-subroom role-permission matrix the 2023
|
||||||
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
|
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
|
||||||
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
|
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
|
||||||
public sealed class SubRoomPermissionEntry
|
|
||||||
{
|
|
||||||
// Field order + CLR types come from the BNLPBMJJOMM constructor
|
|
||||||
// (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role,
|
|
||||||
// Boolean Override, FEOJFGECKGH Type, String Value). The two enum
|
|
||||||
// members serialize as numbers (the role writer boxes them
|
|
||||||
// System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here.
|
|
||||||
public string? Permission { get; set; }
|
|
||||||
public int? Role { get; set; }
|
|
||||||
public bool? Override { get; set; }
|
|
||||||
public int? Type { get; set; }
|
|
||||||
public string? Value { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PublishSubRoomSaveRequest
|
public sealed class PublishSubRoomSaveRequest
|
||||||
{
|
{
|
||||||
public long? SubRoomDataSaveId { get; set; }
|
public long? SubRoomDataSaveId { get; set; }
|
||||||
@@ -3746,6 +3764,7 @@ public class RoomsController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public Task<IActionResult> SubRoomMaxPlayers(
|
public Task<IActionResult> SubRoomMaxPlayers(
|
||||||
long roomId,
|
long roomId,
|
||||||
long subRoomId,
|
long subRoomId,
|
||||||
@@ -3763,6 +3782,7 @@ public class RoomsController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public Task<IActionResult> SubRoomAccessibility(
|
public Task<IActionResult> SubRoomAccessibility(
|
||||||
long roomId,
|
long roomId,
|
||||||
long subRoomId,
|
long subRoomId,
|
||||||
@@ -3773,45 +3793,120 @@ public class RoomsController(
|
|||||||
s.CanMatchmakeInto = a != AccessibilityPrivate;
|
s.CanMatchmakeInto = a != AccessibilityPrivate;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the
|
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — "change
|
||||||
/// role-permission matrix. Client:
|
/// subroom permissions", the role-permission matrix. Client:
|
||||||
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>)</c>
|
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>)</c>
|
||||||
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT
|
/// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room
|
||||||
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c>
|
/// mutation, so the response is the full <c>FGCPNAACHIK</c> details).
|
||||||
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string
|
/// Unlike its siblings the body is NOT form-encoded: the closure calls
|
||||||
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw
|
/// <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> (CCNOMNNHNNG.txt ISIL 009), a raw
|
||||||
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON
|
/// JSON array of cells. The editor sends one changed cell per call
|
||||||
/// deserialize into the List).
|
/// (OMJKHJLFOCO.AFHJLBBFAGP.<LocalRequestModifyRolePermission>g__ChangeAndRefresh
|
||||||
|
/// builds a one-element BNLPBMJJOMM[]), so each cell is upserted; cells not
|
||||||
|
/// sent are left alone.
|
||||||
///
|
///
|
||||||
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no
|
/// <para>Read back through <c>GET photon_access_token</c> (its
|
||||||
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no
|
/// <c>Permissions</c> list), and pushed to everyone in that sub-room as
|
||||||
/// column for it and there is no permissions table. Adding one means an
|
/// the <c>PhotonAccessToken</c> hub message so the change takes effect
|
||||||
/// entity + DbContext + migration, which is outside this file. Until then
|
/// without a rejoin.</para>
|
||||||
/// the matrix is accepted, logged, and the room details echoed back so the
|
///
|
||||||
/// client's promise resolves instead of 415-ing; the values do not survive
|
/// <para>Authorization mirrors the client's own rule
|
||||||
/// a round-trip. The old handler silently bound an unrelated DTO whose keys
|
/// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including
|
||||||
/// the client never sends, which was strictly worse.</para></summary>
|
/// Co-Owner and Creator; a co-owner only the roles below their own;
|
||||||
|
/// nobody else may edit (<see cref="RoomRoleRules.CanEditRolePermissions"/>).</para></summary>
|
||||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> SubRoomPermissions(
|
[RecNetEnvelope]
|
||||||
long roomId,
|
public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
|
||||||
long subRoomId,
|
|
||||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
|
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
if (room.CreatorPlayerId != pid) return Forbid();
|
var roleRows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||||
|
if (!RoomRoleRules.CanEditRoom(room, roleRows, pid)) return Forbid();
|
||||||
|
|
||||||
|
string body;
|
||||||
|
using (var reader = new StreamReader(Request.Body))
|
||||||
|
body = await reader.ReadToEndAsync();
|
||||||
|
var cells = RoomPermissionMatrix.Parse(body);
|
||||||
|
if (cells is null) return BadRequest(new { error = "invalid_permissions_body" });
|
||||||
|
|
||||||
|
var denied = cells.Where(c => !RoomRoleRules.CanEditRolePermissions(room, roleRows, pid, c.Role)).ToList();
|
||||||
|
if (denied.Count > 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"[subroom-permissions] denied room={Room} sub={Sub} by={By} roles={Roles}",
|
||||||
|
roomId, subRoomId, pid, string.Join(",", denied.Select(d => d.Role).Distinct()));
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||||
if (scene is null) return NotFound();
|
if (scene is null) return NotFound();
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
logger.LogWarning(
|
var now = DateTime.UtcNow;
|
||||||
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)",
|
scene.DataModifiedAt = now;
|
||||||
roomId, subRoomId, pid, req?.Count ?? 0);
|
for (var attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
var existing = await db.RoomRolePermissions
|
||||||
|
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var cell in cells)
|
||||||
|
{
|
||||||
|
// Exact match, like the unique index on
|
||||||
|
// (RoomId, SubRoomId, Role, Permission).
|
||||||
|
var row = existing.FirstOrDefault(p =>
|
||||||
|
p.Role == cell.Role && string.Equals(p.Permission, cell.Permission, StringComparison.Ordinal));
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
row = new RoomRolePermissionEntity
|
||||||
|
{
|
||||||
|
RoomId = roomId,
|
||||||
|
SubRoomId = subRoomId,
|
||||||
|
Role = cell.Role,
|
||||||
|
Permission = cell.Permission,
|
||||||
|
};
|
||||||
|
db.RoomRolePermissions.Add(row);
|
||||||
|
existing.Add(row);
|
||||||
|
}
|
||||||
|
row.Type = cell.Type;
|
||||||
|
row.Override = cell.Override;
|
||||||
|
row.Value = cell.Value;
|
||||||
|
row.UpdatedByPlayerId = pid;
|
||||||
|
row.UpdatedAt = now;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException) when (attempt < 3)
|
||||||
|
{
|
||||||
|
// Another editor inserted the same new cell between our read
|
||||||
|
// and our insert (unique index). Forget our pending rows and
|
||||||
|
// apply the cells again, now as updates of theirs.
|
||||||
|
foreach (var entry in db.ChangeTracker.Entries<RoomRolePermissionEntity>().ToList())
|
||||||
|
entry.State = EntityState.Detached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",
|
||||||
|
roomId, subRoomId, pid, cells.Count);
|
||||||
|
|
||||||
|
// Everyone in that sub-room gets the new matrix; the client's
|
||||||
|
// PhotonAccessToken handler re-initialises its permissions from it.
|
||||||
|
var matrix = await RoomPermissionMatrix.LoadAsync(db, roomId, subRoomId);
|
||||||
|
foreach (var (instance, players) in presence.EnumerateActiveInstances(roomId, includePrivate: true))
|
||||||
|
{
|
||||||
|
if (instance.SubRoomId != subRoomId) continue;
|
||||||
|
var tokenBody = RoomPermissionMatrix.AccessTokenBody(instance.RoomInstanceId, matrix);
|
||||||
|
foreach (var other in players)
|
||||||
|
await notifications.NotifyTypedAsync(other, "PhotonAccessToken", tokenBody);
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||||
}
|
}
|
||||||
@@ -3840,12 +3935,14 @@ public class RoomsController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
|
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
if (room.CreatorPlayerId != pid) return Forbid();
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||||
|
|
||||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||||
if (scene is null) return NotFound();
|
if (scene is null) return NotFound();
|
||||||
@@ -3919,12 +4016,14 @@ public class RoomsController(
|
|||||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
||||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
|
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
if (room.CreatorPlayerId != pid) return Forbid();
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||||
var src = await db.RoomScenes
|
var src = await db.RoomScenes
|
||||||
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||||
if (src is null) return NotFound();
|
if (src is null) return NotFound();
|
||||||
@@ -3971,6 +4070,7 @@ public class RoomsController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public Task<IActionResult> ModifySubRoom(
|
public Task<IActionResult> ModifySubRoom(
|
||||||
long roomId,
|
long roomId,
|
||||||
long subRoomId,
|
long subRoomId,
|
||||||
@@ -4010,11 +4110,13 @@ public class RoomsController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> MoveSubRoom(
|
public async Task<IActionResult> MoveSubRoom(
|
||||||
long roomId,
|
long roomId,
|
||||||
long subRoomId,
|
long subRoomId,
|
||||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
|
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
@@ -4105,6 +4207,7 @@ public class RoomsController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
|
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var body = await ReadSaveRoomSceneRequestAsync();
|
var body = await ReadSaveRoomSceneRequestAsync();
|
||||||
body.RoomSceneId = subRoomId;
|
body.RoomSceneId = subRoomId;
|
||||||
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
|
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
|
||||||
@@ -4165,10 +4268,11 @@ public class RoomsController(
|
|||||||
/// successful mutation surface as a client-side parse failure.</summary>
|
/// successful mutation surface as a client-side parse failure.</summary>
|
||||||
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
|
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
|
||||||
{
|
{
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
if (room.CreatorPlayerId != pid) return Forbid();
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||||
if (scene is null) return NotFound();
|
if (scene is null) return NotFound();
|
||||||
mutator(scene);
|
mutator(scene);
|
||||||
@@ -4219,12 +4323,7 @@ public class RoomsController(
|
|||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||||
var list = new List<object>(rows.Count + 1)
|
return Ok(RoomRoleRules.BuildWires(room, rows));
|
||||||
{
|
|
||||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
|
|
||||||
};
|
|
||||||
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
|
|
||||||
return Ok(list);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
|
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
|
||||||
@@ -4339,30 +4438,38 @@ public class RoomsController(
|
|||||||
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
|
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
|
||||||
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task<EFHPLDPNGIM></c>,
|
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task<EFHPLDPNGIM></c>,
|
||||||
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
|
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
|
||||||
/// above.</summary>
|
/// above. The creator answers as Creator (255).</summary>
|
||||||
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
|
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
|
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
|
||||||
{
|
{
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
if (room.CreatorPlayerId == playerId)
|
var rows = await db.RoomRoles
|
||||||
return Ok(BuildRoomAccountRoleWire(playerId, 30));
|
.Where(r => r.RoomId == roomId && r.PlayerId == playerId)
|
||||||
var row = await db.RoomRoles
|
.ToListAsync();
|
||||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
|
var wire = RoomRoleRules.BuildWires(room, rows)
|
||||||
if (row is null) return NotFound();
|
.FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
|
||||||
return Ok(BuildRoomRoleGrantWire(row));
|
if (wire is null) return NotFound();
|
||||||
|
return Ok(wire);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class GrantRoleRequest { public int? Role { get; set; } }
|
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — "change room account
|
||||||
|
/// role" (<c>NLDBPDCNNCF.LMIEIJMCEAB</c>, NLDBPDCNNCF.txt:7516, verb 3 =
|
||||||
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants
|
/// PUT, single form key <c>role</c> boxed as Int32 —
|
||||||
/// a role to a player. Auto-accepted (no invite step).
|
/// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
|
||||||
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role",
|
/// <c>FGCPNAACHIK</c> details. Three callers:
|
||||||
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a
|
/// <list type="bullet">
|
||||||
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
|
/// <item>the owner's role button (AddRemovePlayerRoomRoleConditionalButton
|
||||||
/// <c>FGCPNAACHIK</c> details — which this already does. Only the
|
/// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
|
||||||
/// roomserver/ twin was missing.</summary>
|
/// role 0;</item>
|
||||||
|
/// <item>the invitee accepting a co-owner invite (message Type 62,
|
||||||
|
/// KPFKPIKDBFJ.ELKAEBMMIGL / KLNLKDJJDLE Accept): their OWN account id
|
||||||
|
/// with role 30;</item>
|
||||||
|
/// <item>a co-owner rejecting a direct add (Type 60, COJCABEJDFN Reject):
|
||||||
|
/// their own id with role 0.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Authorization is <see cref="RoomRoleRules.CheckChange"/>.</summary>
|
||||||
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
|
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
|
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
@@ -4372,45 +4479,20 @@ public class RoomsController(
|
|||||||
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> GrantRole(long roomId, long playerId)
|
[RecNetEnvelope]
|
||||||
{
|
public Task<IActionResult> GrantRole(long roomId, long playerId)
|
||||||
var me = this.RequireCurrentPlayerId();
|
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
||||||
if (room is null) return NotFound();
|
|
||||||
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
|
|
||||||
var role = await ReadClientRoomRoleAsync();
|
|
||||||
if (role is null) return BadRequest(new { error = "missing_role" });
|
|
||||||
if (role.Value == 0)
|
|
||||||
return await ClearRoomAccountRoleAsync(room, playerId);
|
|
||||||
|
|
||||||
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
|
/// <summary>PUT <c>rooms/{id}/roles/{playerId}/invite</c> — "invite account
|
||||||
return BadRequest(new { error = "invalid_role", role });
|
/// to role" (<c>NLDBPDCNNCF.KIJDCLINOIC</c>, NLDBPDCNNCF.txt:7686, verb 3,
|
||||||
|
/// form key <c>role</c> as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
|
||||||
var existing = await db.RoomRoles
|
/// Only Co-Owner goes through an invite in this client: the owner's button
|
||||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
|
/// sends role 30 here, the invitee is told with a Type 62
|
||||||
if (existing is null)
|
/// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
|
||||||
{
|
/// <c>PUT roles/{me}</c> role 30 or rejects by sending role 0 back to THIS
|
||||||
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
|
/// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
|
||||||
db.RoomRoles.Add(new RoomRoleEntity
|
/// which clears the pending invite but leaves any role they already
|
||||||
{
|
/// hold.</summary>
|
||||||
RoomId = roomId, PlayerId = playerId, Role = roomRole,
|
|
||||||
Accepted = true, GrantedByPlayerId = me,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
existing.Accepted = true;
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
|
|
||||||
/// as grant but Accepted=false; the target's accept-invite flow
|
|
||||||
/// flips the flag (separate endpoint, not yet exposed). For now
|
|
||||||
/// invited rows surface in RoomDetails.InvitedCoOwners etc.
|
|
||||||
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
|
|
||||||
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
|
|
||||||
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
@@ -4420,62 +4502,168 @@ public class RoomsController(
|
|||||||
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> InviteRole(long roomId, long playerId)
|
[RecNetEnvelope]
|
||||||
|
public Task<IActionResult> InviteRole(long roomId, long playerId)
|
||||||
|
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
|
||||||
|
|
||||||
|
private async Task<IActionResult> ChangeRoomAccountRoleAsync(long roomId, long playerId, bool isInvite)
|
||||||
{
|
{
|
||||||
var me = this.RequireCurrentPlayerId();
|
var me = this.RequireCurrentPlayerId();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
|
var requested = await ReadClientRoomRoleAsync();
|
||||||
var role = await ReadClientRoomRoleAsync();
|
if (requested is null) return BadRequest(new { error = "missing_role" });
|
||||||
if (role is null) return BadRequest(new { error = "missing_role" });
|
if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
|
||||||
if (role.Value == 0)
|
return NotFound(new { error = "unknown_player" });
|
||||||
return await ClearRoomAccountRoleAsync(room, playerId);
|
|
||||||
|
|
||||||
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
|
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||||
return BadRequest(new { error = "invalid_role", role });
|
var error = RoomRoleRules.CheckChange(
|
||||||
|
room, rows, me, playerId, requested.Value, isInvite, out var kind);
|
||||||
var existing = await db.RoomRoles
|
if (error is not null)
|
||||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
|
|
||||||
if (existing is null)
|
|
||||||
{
|
{
|
||||||
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
|
logger.LogWarning(
|
||||||
db.RoomRoles.Add(new RoomRoleEntity
|
"[room-roles] denied room={Room} actor={Actor} target={Target} role={Role} invite={Invite} error={Error}",
|
||||||
|
roomId, me, playerId, requested.Value, isInvite, error);
|
||||||
|
return error == "cannot_change_creator_role" || error == "invalid_role"
|
||||||
|
? BadRequest(new { error, role = requested.Value })
|
||||||
|
: Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
|
||||||
|
var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||||
|
var hadCoOwnerInvite = targetRows.Any(r => !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||||
|
RoomRoleRules.TryFromWire(requested.Value, out var storedRole);
|
||||||
|
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case RoomRoleRules.ChangeKind.Clear when isInvite:
|
||||||
|
// Reject / withdraw an invite: only the pending rows go.
|
||||||
|
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RoomRoleRules.ChangeKind.Clear:
|
||||||
|
db.RoomRoles.RemoveRange(targetRows);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RoomRoleRules.ChangeKind.Invite:
|
||||||
{
|
{
|
||||||
RoomId = roomId, PlayerId = playerId, Role = roomRole,
|
// Already holding the role: nothing to invite to. Otherwise
|
||||||
Accepted = false, GrantedByPlayerId = me,
|
// replace any other pending invite but keep the role the
|
||||||
});
|
// player holds until they accept.
|
||||||
|
if (targetRows.Any(r => r.Accepted && r.Role == storedRole)) break;
|
||||||
|
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted && r.Role != storedRole));
|
||||||
|
var pending = targetRows.FirstOrDefault(r => !r.Accepted && r.Role == storedRole);
|
||||||
|
if (pending is null)
|
||||||
|
{
|
||||||
|
db.RoomRoles.Add(new RoomRoleEntity
|
||||||
|
{
|
||||||
|
RoomId = roomId, PlayerId = playerId, Role = storedRole,
|
||||||
|
Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pending.GrantedByPlayerId = me;
|
||||||
|
pending.GrantedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case RoomRoleRules.ChangeKind.AcceptInvite:
|
||||||
|
case RoomRoleRules.ChangeKind.Grant:
|
||||||
|
{
|
||||||
|
// One role per account: the new one replaces the rest
|
||||||
|
// (an accepted invite replaces the role held meanwhile).
|
||||||
|
var keep = targetRows.FirstOrDefault(r => r.Role == storedRole);
|
||||||
|
db.RoomRoles.RemoveRange(targetRows.Where(r => !ReferenceEquals(r, keep)));
|
||||||
|
if (keep is null)
|
||||||
|
{
|
||||||
|
db.RoomRoles.Add(new RoomRoleEntity
|
||||||
|
{
|
||||||
|
RoomId = roomId, PlayerId = playerId, Role = storedRole,
|
||||||
|
Accepted = true,
|
||||||
|
GrantedByPlayerId = kind == RoomRoleRules.ChangeKind.AcceptInvite
|
||||||
|
? targetRows.FirstOrDefault(r => !r.Accepted)?.GrantedByPlayerId ?? me
|
||||||
|
: me,
|
||||||
|
GrantedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
keep.Accepted = true;
|
||||||
|
if (kind == RoomRoleRules.ChangeKind.Grant) keep.GrantedByPlayerId = me;
|
||||||
|
keep.GrantedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var isCoOwnerNow = await db.RoomRoles.AnyAsync(r =>
|
||||||
|
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||||
|
var hasCoOwnerInviteNow = await db.RoomRoles.AnyAsync(r =>
|
||||||
|
r.RoomId == roomId && r.PlayerId == playerId && !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||||
|
logger.LogInformation(
|
||||||
|
"[room-roles] room={Room} actor={Actor} target={Target} role={Role} invite={Invite} kind={Kind} coOwner={Was}->{Now}",
|
||||||
|
roomId, me, playerId, requested.Value, isInvite, kind, wasCoOwner, isCoOwnerNow);
|
||||||
|
|
||||||
|
// The invitee's notifications are how the role reaches them: the
|
||||||
|
// 2023 client renders message Types 60/61/62 (factory
|
||||||
|
// OCGKOJMFLAI.ALEHDHAEFOK, enum KJECOLODAFA.MEPCALGGMJC) and reads
|
||||||
|
// only RoomId from them. Self-service changes (accept / reject /
|
||||||
|
// leave) notify nobody.
|
||||||
|
if (playerId != me)
|
||||||
{
|
{
|
||||||
existing.Accepted = false;
|
if (!hadCoOwnerInvite && hasCoOwnerInviteNow)
|
||||||
|
await systemNotifications.SendAsync(playerId,
|
||||||
|
SystemNotificationService.MessageType.RoomCoOwnerInvited, me, roomId: roomId);
|
||||||
|
if (!wasCoOwner && isCoOwnerNow)
|
||||||
|
await systemNotifications.SendAsync(playerId,
|
||||||
|
SystemNotificationService.MessageType.RoomCoOwnerAdded, me, roomId: roomId);
|
||||||
|
if (wasCoOwner && !isCoOwnerNow)
|
||||||
|
await systemNotifications.SendAsync(playerId,
|
||||||
|
SystemNotificationService.MessageType.RoomCoOwnerRemoved, me, roomId: roomId);
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
|
||||||
|
await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
|
||||||
|
|
||||||
|
var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
|
||||||
|
await PushRoomUpdateAsync(roomId, details, playerId, me);
|
||||||
|
return Ok(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
|
/// <summary>Once an invite is answered (or withdrawn) its Type 62 message
|
||||||
|
/// must stop offering Accept/Reject in the inbox.</summary>
|
||||||
|
private async Task MarkCoOwnerInviteMessagesReadAsync(long playerId, long roomId)
|
||||||
{
|
{
|
||||||
if (room.CreatorPlayerId == playerId)
|
var stale = await db.Messages
|
||||||
return BadRequest(new { error = "cannot_change_creator_role" });
|
.Where(m => m.RecipientPlayerId == playerId
|
||||||
|
&& m.RoomId == roomId
|
||||||
var rows = await db.RoomRoles
|
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
|
||||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
|
&& m.ReadAt == null)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
if (rows.Count > 0)
|
if (stale.Count == 0) return;
|
||||||
db.RoomRoles.RemoveRange(rows);
|
foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId)
|
/// <summary>Push the fresh room details as <c>RoomUpdate</c> (handler
|
||||||
|
/// IBEOONPEELF.txt:507, Action<FGCPNAACHIK>) to everyone currently
|
||||||
|
/// in the room plus the players whose view changed, so the role list,
|
||||||
|
/// the role-gated buttons and the local player's own role refresh
|
||||||
|
/// without leaving the room.</summary>
|
||||||
|
private async Task PushRoomUpdateAsync(long roomId, object details, params long[] alsoNotify)
|
||||||
{
|
{
|
||||||
if (room.CreatorPlayerId == playerId) return true;
|
var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
|
||||||
return await db.RoomRoles.AnyAsync(r =>
|
.SelectMany(i => i.PlayerIds)
|
||||||
r.RoomId == room.Id
|
.Concat(alsoNotify)
|
||||||
&& r.PlayerId == playerId
|
.Where(pid => pid > 0)
|
||||||
&& r.Accepted
|
.Distinct()
|
||||||
&& r.Role == 0);
|
.ToList();
|
||||||
|
foreach (var pid in recipients)
|
||||||
|
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int?> ReadClientRoomRoleAsync()
|
private async Task<int?> ReadClientRoomRoleAsync()
|
||||||
@@ -4486,6 +4674,8 @@ public class RoomsController(
|
|||||||
var form = await Request.ReadFormAsync();
|
var form = await Request.ReadFormAsync();
|
||||||
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
|
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
|
||||||
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
|
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
|
||||||
|
if (TryParseRoleName(form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault(), out parsed))
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
|
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
|
||||||
@@ -4495,10 +4685,20 @@ public class RoomsController(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||||
Request.Body,
|
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
{
|
||||||
if (body?.Role is int bodyRole) return bodyRole;
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (!prop.Name.Equals("role", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out parsed))
|
||||||
|
return parsed;
|
||||||
|
if (prop.Value.ValueKind == JsonValueKind.String
|
||||||
|
&& (int.TryParse(prop.Value.GetString(), out parsed)
|
||||||
|
|| TryParseRoleName(prop.Value.GetString(), out parsed)))
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
@@ -4509,64 +4709,34 @@ public class RoomsController(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
|
/// <summary>OMMBGJMJJPN member names, for callers that box the enum
|
||||||
|
/// itself (Enum.ToString) instead of an Int32.</summary>
|
||||||
|
internal static bool TryParseRoleName(string? raw, out int role)
|
||||||
{
|
{
|
||||||
var rows = await db.RoomRoles
|
role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
|
|
||||||
.ToListAsync();
|
|
||||||
if (rows.Count > 0)
|
|
||||||
db.RoomRoles.RemoveRange(rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
|
|
||||||
{
|
|
||||||
roomRole = clientRole switch
|
|
||||||
{
|
{
|
||||||
30 => 0, // CoOwner
|
"none" => RoomRoleRules.WireNone,
|
||||||
20 => 1, // Moderator
|
"banned" => RoomRoleRules.WireBanned,
|
||||||
10 => 2, // Host
|
"host" => RoomRoleRules.WireHost,
|
||||||
|
"moderator" => RoomRoleRules.WireModerator,
|
||||||
|
"coowner" => RoomRoleRules.WireCoOwner,
|
||||||
|
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
|
||||||
|
"creator" => RoomRoleRules.WireCreator,
|
||||||
_ => -1,
|
_ => -1,
|
||||||
};
|
};
|
||||||
return roomRole >= 0;
|
return role >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
|
/// <summary>Creator or accepted co-owner (see
|
||||||
=> BuildRoomAccountRoleWire(
|
/// <see cref="RoomRoleRules.CanEditRoom"/>).</summary>
|
||||||
role.PlayerId,
|
private async Task<bool> CanEditRoomAsync(RoomEntity room, long playerId)
|
||||||
ToClientRoomRole(role.Role),
|
|
||||||
role.Accepted,
|
|
||||||
role.GrantedByPlayerId);
|
|
||||||
|
|
||||||
private static object BuildRoomAccountRoleWire(
|
|
||||||
long accountId,
|
|
||||||
int role,
|
|
||||||
bool accepted = true,
|
|
||||||
long? lastChangedByAccountId = null)
|
|
||||||
=> new
|
|
||||||
{
|
|
||||||
AccountId = ToClientAccountId(accountId),
|
|
||||||
Role = accepted ? role : 0,
|
|
||||||
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
|
|
||||||
InvitedRole = accepted ? 0 : role,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static int ToClientRoomRole(int roomRole) => roomRole switch
|
|
||||||
{
|
{
|
||||||
0 => 30, // CoOwner
|
if (room.CreatorPlayerId == playerId) return true;
|
||||||
1 => 20, // Moderator
|
var rows = await db.RoomRoles
|
||||||
2 => 10, // Host
|
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
|
||||||
_ => 0,
|
.ToListAsync();
|
||||||
};
|
return RoomRoleRules.CanEditRoom(room, rows, playerId);
|
||||||
|
}
|
||||||
private static int ToClientAccountId(long accountId)
|
|
||||||
=> accountId > int.MaxValue
|
|
||||||
? int.MaxValue
|
|
||||||
: accountId < int.MinValue
|
|
||||||
? int.MinValue
|
|
||||||
: (int)accountId;
|
|
||||||
|
|
||||||
private static int? ToClientAccountId(long? accountId)
|
|
||||||
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
|
|
||||||
|
|
||||||
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
|
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -501,6 +501,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/description")]
|
[HttpPost("roomserver/rooms/{roomId:long}/description")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/description")]
|
[HttpPut("roomserver/rooms/{roomId:long}/description")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareDescription(long roomId)
|
public async Task<IActionResult> BareDescription(long roomId)
|
||||||
{
|
{
|
||||||
// RawValue, not the whitespace-skipping reader: clearing the
|
// RawValue, not the whitespace-skipping reader: clearing the
|
||||||
@@ -520,6 +521,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/image")]
|
[HttpPost("roomserver/rooms/{roomId:long}/image")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/image")]
|
[HttpPut("roomserver/rooms/{roomId:long}/image")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareImage(long roomId)
|
public async Task<IActionResult> BareImage(long roomId)
|
||||||
{
|
{
|
||||||
var payload = await ReadPayloadAsync();
|
var payload = await ReadPayloadAsync();
|
||||||
@@ -541,6 +543,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
|
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
|
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareTags(long roomId)
|
public async Task<IActionResult> BareTags(long roomId)
|
||||||
{
|
{
|
||||||
var payload = await ReadPayloadAsync();
|
var payload = await ReadPayloadAsync();
|
||||||
@@ -567,6 +570,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
|
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
|
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareAccessibility(long roomId)
|
public async Task<IActionResult> BareAccessibility(long roomId)
|
||||||
{
|
{
|
||||||
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
|
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
|
||||||
@@ -899,6 +903,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
|
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
|
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareCloning(long roomId)
|
public async Task<IActionResult> BareCloning(long roomId)
|
||||||
{
|
{
|
||||||
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
|
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
|
||||||
@@ -918,6 +923,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
|
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
|
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareAutomute(long roomId)
|
public async Task<IActionResult> BareAutomute(long roomId)
|
||||||
{
|
{
|
||||||
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
|
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
|
||||||
@@ -939,6 +945,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
|
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
|
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareRestrictions(long roomId)
|
public async Task<IActionResult> BareRestrictions(long roomId)
|
||||||
{
|
{
|
||||||
var payload = await ReadPayloadAsync();
|
var payload = await ReadPayloadAsync();
|
||||||
@@ -965,6 +972,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
|
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
|
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareWarning(long roomId)
|
public async Task<IActionResult> BareWarning(long roomId)
|
||||||
{
|
{
|
||||||
var payload = await ReadPayloadAsync();
|
var payload = await ReadPayloadAsync();
|
||||||
@@ -983,6 +991,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
|
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
|
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareCreator(long roomId)
|
public async Task<IActionResult> BareCreator(long roomId)
|
||||||
{
|
{
|
||||||
var creatorId = await ReadBareLongAsync(
|
var creatorId = await ReadBareLongAsync(
|
||||||
@@ -1001,6 +1010,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
|
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
|
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareAllowNewUsers(long roomId)
|
public async Task<IActionResult> BareAllowNewUsers(long roomId)
|
||||||
{
|
{
|
||||||
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
|
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
|
||||||
@@ -1015,6 +1025,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
|
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
|
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareMinLevel(long roomId)
|
public async Task<IActionResult> BareMinLevel(long roomId)
|
||||||
{
|
{
|
||||||
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
|
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
|
||||||
@@ -1029,6 +1040,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
|
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
|
||||||
{
|
{
|
||||||
var value = await ReadBareIntAsync(
|
var value = await ReadBareIntAsync(
|
||||||
@@ -1054,6 +1066,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
|
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
|
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareLoadScreen(long roomId)
|
public async Task<IActionResult> BareLoadScreen(long roomId)
|
||||||
{
|
{
|
||||||
var payload = await ReadPayloadAsync();
|
var payload = await ReadPayloadAsync();
|
||||||
@@ -1085,6 +1098,7 @@ public class RoomsModerationController(
|
|||||||
[HttpDelete("rooms/{roomId:long}/loadscreen")]
|
[HttpDelete("rooms/{roomId:long}/loadscreen")]
|
||||||
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
|
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareLoadScreenRemove(long roomId)
|
public async Task<IActionResult> BareLoadScreenRemove(long roomId)
|
||||||
{
|
{
|
||||||
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
|
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
|
||||||
@@ -1121,6 +1135,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
|
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
|
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
|
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
|
||||||
{
|
{
|
||||||
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
||||||
@@ -1137,6 +1152,7 @@ public class RoomsModerationController(
|
|||||||
[HttpDelete("rooms/{roomId:long}/promo_images")]
|
[HttpDelete("rooms/{roomId:long}/promo_images")]
|
||||||
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
|
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
|
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
|
||||||
{
|
{
|
||||||
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
||||||
@@ -1167,6 +1183,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
|
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
|
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
|
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
|
||||||
{
|
{
|
||||||
var payload = await ReadPayloadAsync();
|
var payload = await ReadPayloadAsync();
|
||||||
@@ -1197,6 +1214,7 @@ public class RoomsModerationController(
|
|||||||
[HttpDelete("rooms/{roomId:long}/promo_external")]
|
[HttpDelete("rooms/{roomId:long}/promo_external")]
|
||||||
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
|
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
|
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
|
||||||
{
|
{
|
||||||
var payload = await ReadPayloadAsync();
|
var payload = await ReadPayloadAsync();
|
||||||
@@ -1275,6 +1293,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareAck(long roomId) =>
|
public async Task<IActionResult> BareAck(long roomId) =>
|
||||||
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
|
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
|
||||||
|
|
||||||
@@ -1305,6 +1324,7 @@ public class RoomsModerationController(
|
|||||||
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
|
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
|
||||||
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
|
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
[RecNetEnvelope]
|
||||||
public async Task<IActionResult> BareModify(long roomId)
|
public async Task<IActionResult> BareModify(long roomId)
|
||||||
{
|
{
|
||||||
var payload = await ReadPayloadAsync();
|
var payload = await ReadPayloadAsync();
|
||||||
@@ -1375,13 +1395,13 @@ public class RoomsModerationController(
|
|||||||
{
|
{
|
||||||
var newName = await ReadCloneNameAsync();
|
var newName = await ReadCloneNameAsync();
|
||||||
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (source is null) return NotFound();
|
if (source is null) return CloneResult(null, "That room doesn't exist.");
|
||||||
// RRO/AG rooms are first-party templates meant to be cloned — the
|
// RRO/AG rooms are first-party templates meant to be cloned — the
|
||||||
// room-creation "base room" picker starts a new build by cloning one
|
// room-creation "base room" picker starts a new build by cloning one
|
||||||
// (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false,
|
// (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false,
|
||||||
// so without the IsAGRoom exception every base-room create 403s.
|
// so without the IsAGRoom exception every base-room create is refused.
|
||||||
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
|
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
|
||||||
return Forbid();
|
return CloneResult(null, "You can't clone this room!");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(newName))
|
if (string.IsNullOrWhiteSpace(newName))
|
||||||
newName = $"{source.Name}Copy";
|
newName = $"{source.Name}Copy";
|
||||||
@@ -1432,6 +1452,13 @@ public class RoomsModerationController(
|
|||||||
// it editable. Safe for admin: the purge gate and "RR Original"
|
// it editable. Safe for admin: the purge gate and "RR Original"
|
||||||
// badge both key on CreatorPlayerId == SystemAccountId, so a
|
// badge both key on CreatorPlayerId == SystemAccountId, so a
|
||||||
// user-owned clone is still shown as Custom and stays purgeable.
|
// user-owned clone is still shown as Custom and stays purgeable.
|
||||||
|
//
|
||||||
|
// The WIRE IsRRO is a different thing: it is false for every
|
||||||
|
// player-owned room (RoomService.WireIsRRO). The 2023 client's
|
||||||
|
// room-load pipeline never reads IsRRO (the baked scene comes
|
||||||
|
// from the sub-room's UnitySceneId, the blob step from DataBlob),
|
||||||
|
// while its owner UI refuses publish and every room setting on
|
||||||
|
// an RRO, which is what kept cloned rooms from being published.
|
||||||
IsAGRoom = source.IsAGRoom,
|
IsAGRoom = source.IsAGRoom,
|
||||||
IsDormRoom = false,
|
IsDormRoom = false,
|
||||||
CloningAllowed = source.CloningAllowed,
|
CloningAllowed = source.CloningAllowed,
|
||||||
@@ -1509,6 +1536,18 @@ public class RoomsModerationController(
|
|||||||
CanMatchmakeInto = s.CanMatchmakeInto,
|
CanMatchmakeInto = s.CanMatchmakeInto,
|
||||||
DataModifiedAt = DateTime.UtcNow,
|
DataModifiedAt = DateTime.UtcNow,
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
// A template with no scene rows still has to give the new room a
|
||||||
|
// real entry sub-room (named "Home", from the clone's own location,
|
||||||
|
// blob and capacity). Without it the details served a synthesised
|
||||||
|
// placeholder and Room Settings > Subrooms showed an empty row.
|
||||||
|
if (clonedScenes.Count == 0)
|
||||||
|
{
|
||||||
|
var home = Services.RoomService.NewHomeScene(clone);
|
||||||
|
home.Id = ++nextSceneId;
|
||||||
|
clonedScenes.Add(home);
|
||||||
|
}
|
||||||
|
foreach (var scene in clonedScenes.Where(s => string.IsNullOrWhiteSpace(s.Name)))
|
||||||
|
scene.Name = Services.RoomService.DefaultSubRoomName(scene.OrderIndex);
|
||||||
if (clonedScenes.Count > 0)
|
if (clonedScenes.Count > 0)
|
||||||
{
|
{
|
||||||
db.RoomScenes.AddRange(clonedScenes);
|
db.RoomScenes.AddRange(clonedScenes);
|
||||||
@@ -1517,17 +1556,22 @@ public class RoomsModerationController(
|
|||||||
|
|
||||||
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
|
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
|
||||||
|
|
||||||
// The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN)
|
// The value is the FULL room-details object (FGCPNAACHIK), the exact
|
||||||
// deserializes the response as the FULL room-details object
|
// shape GET rooms/{id} returns for the 2023 client — not the slim
|
||||||
// (FGCPNAACHIK — the same type get-by-id / rename return on the
|
// list-room shape, which made the strict reader fail.
|
||||||
// roomserver host), NOT the slim list-room shape. This is the
|
return CloneResult(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
||||||
// roomserver-family path (rooms/{id}/clone), so use
|
|
||||||
// BuildRoomServerDetails — the exact shape GET rooms/{id} returns for
|
|
||||||
// the 2023 client. Returning ToWireRoom (or the wrong details variant)
|
|
||||||
// made the strict reader fail → "Failed to copy room: Failed to clone
|
|
||||||
// room".
|
|
||||||
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
|
||||||
}
|
}
|
||||||
|
/// <summary>Reply for <c>rooms/{id}/clone</c>. The 2023 client sends the
|
||||||
|
/// clone through the same RecNet rooms helper as "save room" and "restore
|
||||||
|
/// subroom save" (NLDBPDCNNCF.BDBHCEGKGDC with an op name), which reads the
|
||||||
|
/// legacy <c>{success, value, error}</c> envelope. A bare room-details body
|
||||||
|
/// has no <c>success</c>, so the client rejects it with an empty error:
|
||||||
|
/// "Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown", even
|
||||||
|
/// though the room was created. Refusals are 200 with success=false and a
|
||||||
|
/// message the client shows.</summary>
|
||||||
|
private OkObjectResult CloneResult(object? room, string error = "") =>
|
||||||
|
Ok(new { success = error.Length == 0, value = room, error });
|
||||||
|
|
||||||
/// <summary>Give a freshly-cloned room its OWN copy of the source's save
|
/// <summary>Give a freshly-cloned room its OWN copy of the source's save
|
||||||
/// data instead of pointing its scenes at the source's blob.
|
/// data instead of pointing its scenes at the source's blob.
|
||||||
///
|
///
|
||||||
@@ -1575,8 +1619,11 @@ public class RoomsModerationController(
|
|||||||
// The scene was created as a shallow copy, so its DataBlobName is
|
// The scene was created as a shallow copy, so its DataBlobName is
|
||||||
// currently the SOURCE's. Either a copy replaces it or it gets
|
// currently the SOURCE's. Either a copy replaces it or it gets
|
||||||
// cleared — it must never survive as-is.
|
// cleared — it must never survive as-is.
|
||||||
|
// A synthesised Home scene (template had no rows) has no source
|
||||||
|
// scene; it starts from the room-level blob it was built with.
|
||||||
|
var sourceBlob = i < sourceScenes.Count ? sourceScenes[i].DataBlobName : target.DataBlobName;
|
||||||
var copied = await CopyBlobForCloneAsync(
|
var copied = await CopyBlobForCloneAsync(
|
||||||
sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId);
|
sourceBlob, clone.Id, target.OrderIndex, callerId);
|
||||||
|
|
||||||
target.DataBlobName = copied ?? string.Empty;
|
target.DataBlobName = copied ?? string.Empty;
|
||||||
target.DataModifiedAt = DateTime.UtcNow;
|
target.DataModifiedAt = DateTime.UtcNow;
|
||||||
@@ -1887,7 +1934,13 @@ public class RoomsModerationController(
|
|||||||
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
|
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
|
||||||
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
|
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
|
||||||
/// reader's dispose-walk NRE'd on the missing nested lists and every
|
/// reader's dispose-walk NRE'd on the missing nested lists and every
|
||||||
/// mutation reported failure even though it had persisted.</summary>
|
/// mutation reported failure even though it had persisted.
|
||||||
|
///
|
||||||
|
/// <para>That helper is <c>NLDBPDCNNCF.BDBHCEGKGDC<TResult></c>, the
|
||||||
|
/// <c>{success, value, error}</c> envelope reader, so the details are the
|
||||||
|
/// envelope's <c>value</c>: every caller carries
|
||||||
|
/// <see cref="Controllers.RecNetEnvelopeAttribute"/>. A bare details body
|
||||||
|
/// fails with "Exception of type 'CEMNLBKJABA' was thrown."</para></summary>
|
||||||
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
|
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
|
||||||
{
|
{
|
||||||
var scenes = await db.RoomScenes
|
var scenes = await db.RoomScenes
|
||||||
|
|||||||
@@ -156,9 +156,19 @@ public class StorefrontsBuyController(
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
// Push the buyer's balance update so their wallet UI refreshes.
|
// Push the buyer's balance update so their wallet UI refreshes.
|
||||||
|
// StorefrontBalancePurchase "62" is PurchaseBalanceModificationDTO
|
||||||
|
// {BalanceAddType, Delta, Balance, Platform, CurrencyType}; Balance
|
||||||
|
// is the absolute new balance, Delta only feeds the client's log.
|
||||||
await notifications.NotifyAsync(pid,
|
await notifications.NotifyAsync(pid,
|
||||||
PushNotificationId.StorefrontBalancePurchase,
|
PushNotificationId.StorefrontBalancePurchase,
|
||||||
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
|
new
|
||||||
|
{
|
||||||
|
BalanceAddType = 0,
|
||||||
|
Delta = (int)-item.Price,
|
||||||
|
Balance = newBalance,
|
||||||
|
Platform = 0,
|
||||||
|
req.CurrencyType,
|
||||||
|
});
|
||||||
|
|
||||||
if (isGift)
|
if (isGift)
|
||||||
{
|
{
|
||||||
@@ -166,24 +176,13 @@ public class StorefrontsBuyController(
|
|||||||
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
|
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
|
||||||
// and pop the "you got a gift" toast; the gift box itself
|
// and pop the "you got a gift" toast; the gift box itself
|
||||||
// appears next time the recipient opens their inventory.
|
// appears next time the recipient opens their inventory.
|
||||||
|
// GiftPackageReceived "30" carries the client's full gift package
|
||||||
|
// (FHMABOHAEED) — the same object GET api/avatar/v2/gifts lists.
|
||||||
|
// A hand-picked subset left out PlatformsToSpawnOn (-1 = every
|
||||||
|
// platform), so the box could count as spawning nowhere.
|
||||||
await notifications.NotifyAsync(recipient,
|
await notifications.NotifyAsync(recipient,
|
||||||
PushNotificationId.GiftPackageReceived,
|
PushNotificationId.GiftPackageReceived,
|
||||||
new
|
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
|
||||||
{
|
|
||||||
Id = gift.Id,
|
|
||||||
Platform = -1,
|
|
||||||
Xp = 0,
|
|
||||||
Level = 1,
|
|
||||||
GiftRarity = 0,
|
|
||||||
GiftContext = gift.GiftContext,
|
|
||||||
FromPlayerId = gift.FromPlayerId,
|
|
||||||
AvatarItemType = gift.AvatarItemType,
|
|
||||||
AvatarItemDesc = avatarItemDesc,
|
|
||||||
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
|
|
||||||
EquipmentPrefabName = gift.EquipmentPrefabName,
|
|
||||||
EquipmentModificationGuid = gift.EquipmentModificationGuid,
|
|
||||||
Message = gift.Message ?? string.Empty,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For gift purchases the buyer doesn't get a box — their flow
|
// For gift purchases the buyer doesn't get a box — their flow
|
||||||
|
|||||||
@@ -177,7 +177,10 @@ public class SubscriptionsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok();
|
// The client reads this body as DJMHAFPGLLN, the {success,error}
|
||||||
|
// RecNetResult (FGLDKEJLAKB<DJMHAFPGLLN> at IKMMOCKDKAF.txt LPFOPPHGDHG,
|
||||||
|
// instr 098) — answer in that shape rather than an empty 200.
|
||||||
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public class AccountsController(
|
|||||||
{
|
{
|
||||||
var player = await GetCurrentPlayerAsync();
|
var player = await GetCurrentPlayerAsync();
|
||||||
if (player is null) return Unauthorized();
|
if (player is null) return Unauthorized();
|
||||||
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
return Ok(AccountDtos.Self(player));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -154,7 +154,7 @@ public class AccountsController(
|
|||||||
playerId, username, summary);
|
playerId, username, summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
await notifications.KickPlayerAsync(playerId, "This account has been deleted.");
|
await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||||
presence.Clear(playerId);
|
presence.Clear(playerId);
|
||||||
|
|
||||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
@@ -194,7 +194,7 @@ public class AccountsController(
|
|||||||
{
|
{
|
||||||
var player = await playerService.GetByIdAsync(accountId);
|
var player = await playerService.GetByIdAsync(accountId);
|
||||||
if (player is null) return NotFound();
|
if (player is null) return NotFound();
|
||||||
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
return Ok(AccountDtos.Public(player));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accounts.GetAccountByUsername
|
// Accounts.GetAccountByUsername
|
||||||
@@ -203,7 +203,7 @@ public class AccountsController(
|
|||||||
{
|
{
|
||||||
var player = await playerService.GetByUsernameAsync(username);
|
var player = await playerService.GetByUsernameAsync(username);
|
||||||
if (player is null) return NotFound();
|
if (player is null) return NotFound();
|
||||||
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
return Ok(AccountDtos.Public(player));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accounts.CreateAccount — actual URL observed in server logs:
|
// Accounts.CreateAccount — actual URL observed in server logs:
|
||||||
@@ -263,7 +263,7 @@ public class AccountsController(
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Error = string.Empty,
|
Error = string.Empty,
|
||||||
Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName),
|
Account = AccountDtos.Public(player),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ public class AccountsController(
|
|||||||
var clamped = Math.Clamp(take, 1, 50);
|
var clamped = Math.Clamp(take, 1, 50);
|
||||||
var matches = await playerService.SearchAsync(term.Trim(), clamped);
|
var matches = await playerService.SearchAsync(term.Trim(), clamped);
|
||||||
return Ok(matches
|
return Ok(matches
|
||||||
.Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName))
|
.Select(p => AccountDtos.Public(p))
|
||||||
.ToList());
|
.ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +302,12 @@ public class AccountsController(
|
|||||||
[HttpPost("/account/v1/emails")]
|
[HttpPost("/account/v1/emails")]
|
||||||
[HttpPost("/account/v1/phones")]
|
[HttpPost("/account/v1/phones")]
|
||||||
[HttpPost("/account/v1/byplatformid")]
|
[HttpPost("/account/v1/byplatformid")]
|
||||||
|
// 2023 contact lookup: OPMAPIOEIFG.HHAAAPKCLMN (RecNet.Runtime/OPMAPIOEIFG.txt:11864)
|
||||||
|
// POSTs "account/bulk/" + "email" or "phone" (:11590-11620, verb 2) with the
|
||||||
|
// values as repeated form fields of the same name, and reads the same account
|
||||||
|
// list (List<BLOJAFNJPJI>) as account/bulk. Both suffixes 404'd.
|
||||||
|
[HttpPost("/account/bulk/email")]
|
||||||
|
[HttpPost("/account/bulk/phone")]
|
||||||
public async Task<IActionResult> Bulk()
|
public async Task<IActionResult> Bulk()
|
||||||
{
|
{
|
||||||
// The watch calls this route family with payloads keyed by
|
// The watch calls this route family with payloads keyed by
|
||||||
@@ -401,7 +407,7 @@ public class AccountsController(
|
|||||||
{
|
{
|
||||||
var p = await playerService.GetByIdAsync(id);
|
var p = await playerService.GetByIdAsync(id);
|
||||||
if (p is not null)
|
if (p is not null)
|
||||||
accounts.Add(BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName));
|
accounts.Add(AccountDtos.Public(p));
|
||||||
}
|
}
|
||||||
return Ok(accounts);
|
return Ok(accounts);
|
||||||
}
|
}
|
||||||
@@ -433,8 +439,11 @@ public class AccountsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Phone-confirm is the one mutation we don't persist — there's no
|
// Phone-confirm is the one mutation we don't persist — there's no
|
||||||
// SMS provider in the loop. Acknowledge silently.
|
// SMS provider in the loop. Acknowledge silently. The 2023 client
|
||||||
|
// composes the route as "account/me/" + "confirmphone" with POST
|
||||||
|
// (OPMAPIOEIFG.OBAGJBBIFGB) and reads the RecNetResult body.
|
||||||
[HttpPost("/account/v1/confirmphone")]
|
[HttpPost("/account/v1/confirmphone")]
|
||||||
|
[HttpPost("/account/me/confirmphone")]
|
||||||
public IActionResult ConfirmPhone() =>
|
public IActionResult ConfirmPhone() =>
|
||||||
Ok(new RecNetResult { Success = true, Error = string.Empty });
|
Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
|
||||||
@@ -629,6 +638,126 @@ public class AccountsController(
|
|||||||
return Ok(new { success = true, error = "" });
|
return Ok(new { success = true, error = "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 2023 profile edits ────────────────────────────────────────────
|
||||||
|
// All four go through OPMAPIOEIFG.OBAGJBBIFGB, which builds
|
||||||
|
// "account/me/" + suffix (RecNet.Runtime OPMAPIOEIFG.txt:8579) and
|
||||||
|
// reports "Failed to modify <suffix>" on any non-2xx (:8609). Every call
|
||||||
|
// site passes verb 3 = PUT. The form field name is NOT the suffix — each
|
||||||
|
// comes from the lambda that fills the request:
|
||||||
|
// emoji → "displayEmoji" (OPMAPIOEIFG_NestedType_KPGNAEPGLAC.txt:57)
|
||||||
|
// personalpronouns → "pronounFlags" (…_LCCEHLBIOPK.txt:67, boxed Int32)
|
||||||
|
// identityflags → "identityFlags" (…_HHDFMJPLFHG.txt:67, boxed Int32)
|
||||||
|
// bannerimage → "imageName" (…_NJKHNGEPODH.txt:57)
|
||||||
|
// The response body is never parsed (LDGADANDBIO handle), so the new
|
||||||
|
// value reaches the editor's UI and other players only through the
|
||||||
|
// AccountUpdate / SelfAccountUpdate push in NotificationService.
|
||||||
|
// None of these routes existed before, so every edit 404'd.
|
||||||
|
|
||||||
|
/// <summary>PUT <c>/account/me/emoji</c> — form <c>displayEmoji</c>.
|
||||||
|
/// Empty clears the emoji; anything else must be on the
|
||||||
|
/// <c>emojiConfig/whitelistedEmojis</c> list the picker was filled
|
||||||
|
/// from, and is stored in that list's canonical spelling.</summary>
|
||||||
|
[HttpPut("/account/me/emoji")]
|
||||||
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||||
|
public async Task<IActionResult> ChangeEmoji([FromForm(Name = "displayEmoji")] string? displayEmoji)
|
||||||
|
{
|
||||||
|
var player = await GetCurrentPlayerAsync();
|
||||||
|
if (player is null) return Unauthorized();
|
||||||
|
|
||||||
|
var submitted = displayEmoji?.Trim() ?? string.Empty;
|
||||||
|
string stored;
|
||||||
|
if (submitted.Length == 0)
|
||||||
|
{
|
||||||
|
stored = string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var canonical = ResolveWhitelistedEmoji(submitted);
|
||||||
|
if (canonical is null)
|
||||||
|
return BadRequest(new RecNetResult { Success = false, Error = "emoji_not_allowed" });
|
||||||
|
stored = canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.DisplayEmoji = stored;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await notifications.AccountChanged(player);
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whitelist match that ignores U+FE0F variation selectors, so
|
||||||
|
/// "❤" and "❤️" resolve to the same entry. Returns the list's spelling
|
||||||
|
/// (the client highlights the current pick by string equality against
|
||||||
|
/// the list it fetched).</summary>
|
||||||
|
internal static string? ResolveWhitelistedEmoji(string value)
|
||||||
|
{
|
||||||
|
static string Strip(string s) => s.Replace("️", string.Empty, StringComparison.Ordinal);
|
||||||
|
var needle = Strip(value);
|
||||||
|
foreach (var e in API.Config.V1.ConfigController.WhitelistedEmojiList)
|
||||||
|
if (string.Equals(Strip(e), needle, StringComparison.Ordinal)) return e;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PUT <c>/account/me/personalpronouns</c> — form
|
||||||
|
/// <c>pronounFlags</c> (pronoun flags enum as Int32).</summary>
|
||||||
|
[HttpPut("/account/me/personalpronouns")]
|
||||||
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||||
|
public async Task<IActionResult> ChangePersonalPronouns([FromForm(Name = "pronounFlags")] string? pronounFlags)
|
||||||
|
{
|
||||||
|
var player = await GetCurrentPlayerAsync();
|
||||||
|
if (player is null) return Unauthorized();
|
||||||
|
if (!TryParseFlags(pronounFlags, out var flags))
|
||||||
|
return BadRequest(new RecNetResult { Success = false, Error = "invalid_pronouns" });
|
||||||
|
|
||||||
|
player.PersonalPronouns = flags;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await notifications.AccountChanged(player);
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PUT <c>/account/me/identityflags</c> — form
|
||||||
|
/// <c>identityFlags</c> (identity flags enum as Int32).</summary>
|
||||||
|
[HttpPut("/account/me/identityflags")]
|
||||||
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||||
|
public async Task<IActionResult> ChangeIdentityFlags([FromForm(Name = "identityFlags")] string? identityFlags)
|
||||||
|
{
|
||||||
|
var player = await GetCurrentPlayerAsync();
|
||||||
|
if (player is null) return Unauthorized();
|
||||||
|
if (!TryParseFlags(identityFlags, out var flags))
|
||||||
|
return BadRequest(new RecNetResult { Success = false, Error = "invalid_identity_flags" });
|
||||||
|
|
||||||
|
player.IdentityFlags = flags;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await notifications.AccountChanged(player);
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Flags arrive as the boxed enum's Int32 ToString(). Negative
|
||||||
|
/// values are not valid bitmasks for either enum.</summary>
|
||||||
|
private static bool TryParseFlags(string? raw, out int flags)
|
||||||
|
=> int.TryParse(raw?.Trim(), System.Globalization.NumberStyles.Integer,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out flags)
|
||||||
|
&& flags >= 0;
|
||||||
|
|
||||||
|
/// <summary>PUT <c>/account/me/bannerimage</c> — form <c>imageName</c>,
|
||||||
|
/// the storage name of an image the player already uploaded (the
|
||||||
|
/// portfolio-banner picker offers the player's own photos). Empty clears
|
||||||
|
/// the banner.</summary>
|
||||||
|
[HttpPut("/account/me/bannerimage")]
|
||||||
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||||
|
public async Task<IActionResult> ChangeBannerImage([FromForm(Name = "imageName")] string? imageName)
|
||||||
|
{
|
||||||
|
var player = await GetCurrentPlayerAsync();
|
||||||
|
if (player is null) return Unauthorized();
|
||||||
|
var name = imageName?.Trim() ?? string.Empty;
|
||||||
|
if (name.Length > 256)
|
||||||
|
return BadRequest(new RecNetResult { Success = false, Error = "invalid_image" });
|
||||||
|
|
||||||
|
player.BannerImageName = name.Length == 0 ? null : name;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await notifications.AccountChanged(player);
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
// ParentalControl.Deserialize at RVA 0x14543A0 — required keys:
|
// ParentalControl.Deserialize at RVA 0x14543A0 — required keys:
|
||||||
// accountId (int, GetKey)
|
// accountId (int, GetKey)
|
||||||
// disallowInAppPurchases (bool, GetKey)
|
// disallowInAppPurchases (bool, GetKey)
|
||||||
@@ -680,63 +809,56 @@ public class AccountsController(
|
|||||||
|
|
||||||
var token = Guid.NewGuid().ToString("N");
|
var token = Guid.NewGuid().ToString("N");
|
||||||
var expiresAt = DateTime.UtcNow.AddMinutes(10);
|
var expiresAt = DateTime.UtcNow.AddMinutes(10);
|
||||||
await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}");
|
await SetPlayerSettingAsync(player.Id, RemoteLoginTokenKey, $"{token}|{expiresAt:O}");
|
||||||
return Ok(new
|
// FGLDKEJLAKB<System.String> LGKLFFPACGL() (RecNet.Runtime/
|
||||||
{
|
// LBNJFPOLCDL.txt:8382) deserialises the reply as a BARE JSON string.
|
||||||
Success = true,
|
// The old {Success, Token, ...} object hit the string reader's '"'
|
||||||
Error = string.Empty,
|
// check and every "open on the web" link failed with "Failed to
|
||||||
Token = token,
|
// CreateLoginToken". The token is redeemed by connect/token
|
||||||
LoginToken = token,
|
// grant_type=login_token (AuthController.Token).
|
||||||
ExpiresAt = expiresAt,
|
return Content(System.Text.Json.JsonSerializer.Serialize(token), "application/json");
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>PlayerSettings key of the one-shot token minted above.
|
||||||
|
/// Value is <c>{token}|{expiresAtUtc:O}</c>.</summary>
|
||||||
|
public const string RemoteLoginTokenKey = "remote_login_token";
|
||||||
|
|
||||||
|
/// <summary>In-game approval of another device's login code.
|
||||||
|
///
|
||||||
|
/// <c>LDGADANDBIO BJMMDKNPCLD(String)</c> (RecNet.Runtime/LBNJFPOLCDL.txt:7896)
|
||||||
|
/// POSTs the single form field <c>code</c> and reads the reply through
|
||||||
|
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> as a <c>{success,error}</c> RecNetResult
|
||||||
|
/// (failure toast "Failed to authorize login"). The code is the
|
||||||
|
/// <c>user_code</c> that <c>connect/deviceauthorization</c> handed the other
|
||||||
|
/// device, which meanwhile polls <c>connect/token</c> with
|
||||||
|
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c>
|
||||||
|
/// (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451). Approving binds the
|
||||||
|
/// pending authorization to the CALLER, and that device's next poll is
|
||||||
|
/// issued the caller's tokens (<see cref="DeviceAuthorizations"/>).
|
||||||
|
///
|
||||||
|
/// This used to look the code up among createlogintoken tokens — which a
|
||||||
|
/// user_code never matches, so every approval 401'd — and answered with a
|
||||||
|
/// token pair, which is not a RecNetResult either.</summary>
|
||||||
[HttpPost("/account/me/remoteauth")]
|
[HttpPost("/account/me/remoteauth")]
|
||||||
[HttpGet("/account/me/remoteauth")]
|
[HttpGet("/account/me/remoteauth")]
|
||||||
public async Task<IActionResult> RemoteAuth()
|
public async Task<IActionResult> RemoteAuth()
|
||||||
{
|
{
|
||||||
var token = Request.Query["token"].FirstOrDefault()
|
var approver = await GetCurrentPlayerAsync();
|
||||||
?? Request.Query["loginToken"].FirstOrDefault()
|
if (approver is null) return Unauthorized();
|
||||||
?? Request.Query["code"].FirstOrDefault();
|
|
||||||
if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType)
|
var code = Request.Query["code"].FirstOrDefault()
|
||||||
|
?? Request.Query["user_code"].FirstOrDefault();
|
||||||
|
if (string.IsNullOrWhiteSpace(code) && Request.HasFormContentType)
|
||||||
{
|
{
|
||||||
var form = await Request.ReadFormAsync();
|
var form = await Request.ReadFormAsync();
|
||||||
token = form["token"].FirstOrDefault()
|
code = form["code"].FirstOrDefault() ?? form["user_code"].FirstOrDefault();
|
||||||
?? form["loginToken"].FirstOrDefault()
|
|
||||||
?? form["code"].FirstOrDefault();
|
|
||||||
}
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(code))
|
||||||
|
return RecNetResults.Fail("missing_code");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
return await DeviceAuthorizations.ApproveAsync(db, code, approver.Id, DateTime.UtcNow)
|
||||||
{
|
? RecNetResults.Ok()
|
||||||
var player = await GetCurrentPlayerAsync();
|
: RecNetResults.Fail("invalid_or_expired_code");
|
||||||
if (player is null) return Unauthorized();
|
|
||||||
var pair = authService.GenerateTokenPair(player.Id);
|
|
||||||
return Ok(new LoginResponse
|
|
||||||
{
|
|
||||||
AccessToken = pair.AccessToken,
|
|
||||||
RefreshToken = pair.RefreshToken,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var prefix = token.Trim() + "|";
|
|
||||||
var row = await db.PlayerSettings
|
|
||||||
.Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix))
|
|
||||||
.OrderByDescending(s => s.Id)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (row is null) return Unauthorized();
|
|
||||||
|
|
||||||
var rawExpiry = row.Value[prefix.Length..];
|
|
||||||
if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
db.PlayerSettings.Remove(row);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
var (access, refresh) = authService.GenerateTokenPair(row.PlayerId);
|
|
||||||
return Ok(new LoginResponse
|
|
||||||
{
|
|
||||||
AccessToken = access,
|
|
||||||
RefreshToken = refresh,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/account/recoverpassword")]
|
[HttpPost("/account/recoverpassword")]
|
||||||
@@ -953,8 +1075,15 @@ public class AccountsController(
|
|||||||
var me = this.RequireCurrentPlayerId();
|
var me = this.RequireCurrentPlayerId();
|
||||||
var visible = await ReadRecentHistoryVisibleBodyAsync();
|
var visible = await ReadRecentHistoryVisibleBodyAsync();
|
||||||
await SetPlayerSettingAsync(me, RecentHistoryVisibleKey, visible ? "true" : "false");
|
await SetPlayerSettingAsync(me, RecentHistoryVisibleKey, visible ? "true" : "false");
|
||||||
|
// OPMAPIOEIFG.PNEDPCJCKHI passes this body to BFDGEKKNAPB.PEFKBAEAOPD
|
||||||
|
// (ExpandRecNetResult) — it is read as {Success, Error}. Without a
|
||||||
|
// Success key the toggle parsed as a failure with a null Error, which
|
||||||
|
// throws "Error message cannot be null" instead of saving quietly.
|
||||||
|
// The settings keys stay for any reader of the old shape.
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
Success = true,
|
||||||
|
Error = string.Empty,
|
||||||
AccountId = (int)me,
|
AccountId = (int)me,
|
||||||
IsRecentHistoryVisible = visible,
|
IsRecentHistoryVisible = visible,
|
||||||
});
|
});
|
||||||
@@ -995,35 +1124,4 @@ public class AccountsController(
|
|||||||
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
|
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
|
||||||
// unknown accounts URLs now 404 so we notice them in logs and wire
|
// unknown accounts URLs now 404 so we notice them in logs and wire
|
||||||
// them properly rather than silently masking missing endpoints.
|
// them properly rather than silently masking missing endpoints.
|
||||||
|
|
||||||
private static RecNetAccount BuildAccount(long id, string username, string? displayName, string? profileImage = null)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
AccountId = (int)id,
|
|
||||||
RawUsername = username,
|
|
||||||
Username = username,
|
|
||||||
DisplayName = displayName ?? username,
|
|
||||||
ProfileImage = profileImage ?? string.Empty,
|
|
||||||
TreatAsJunior = false,
|
|
||||||
HasBirthday = true,
|
|
||||||
Platforms = 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static RecNetSelfAccount BuildSelfAccount(long id, string username, string? displayName, string? profileImage = null)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
AccountId = (int)id,
|
|
||||||
RawUsername = username,
|
|
||||||
Username = username,
|
|
||||||
DisplayName = displayName ?? username,
|
|
||||||
ProfileImage = profileImage ?? string.Empty,
|
|
||||||
TreatAsJunior = false,
|
|
||||||
HasBirthday = true,
|
|
||||||
Platforms = 1,
|
|
||||||
Email = string.Empty,
|
|
||||||
Phone = string.Empty,
|
|
||||||
Birthday = new DateTime(2000, 1, 1),
|
|
||||||
JuniorState = 0,
|
|
||||||
ParentAccountId = null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using DorkNet.Server.Data;
|
|||||||
using DorkNet.Server.Data.Entities;
|
using DorkNet.Server.Data.Entities;
|
||||||
using DorkNet.Server.Hubs;
|
using DorkNet.Server.Hubs;
|
||||||
using DorkNet.Server.Services;
|
using DorkNet.Server.Services;
|
||||||
|
using RecRoom.Protobuf;
|
||||||
|
|
||||||
namespace DorkNet.Server.Controllers.Admin;
|
namespace DorkNet.Server.Controllers.Admin;
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ namespace DorkNet.Server.Controllers.Admin;
|
|||||||
public class AdminController(
|
public class AdminController(
|
||||||
DorkNetDbContext db,
|
DorkNetDbContext db,
|
||||||
NotificationService notifications,
|
NotificationService notifications,
|
||||||
|
SystemNotificationService systemNotifications,
|
||||||
LevelService level,
|
LevelService level,
|
||||||
CommunityBoardService communityBoard,
|
CommunityBoardService communityBoard,
|
||||||
RoomBlobNormalizerService roomBlobNormalizer,
|
RoomBlobNormalizerService roomBlobNormalizer,
|
||||||
@@ -159,7 +161,9 @@ public class AdminController(
|
|||||||
await LogAsync("ban_player", "player", id, body.Reason ?? "");
|
await LogAsync("ban_player", "player", id, body.Reason ?? "");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned.");
|
// A ban must sign the player out (a kick only sends them to the dorm);
|
||||||
|
// the next login is refused by the ban check.
|
||||||
|
await notifications.LogoutAsync(id, NotificationService.LogoutReason.Banned);
|
||||||
playerPresence.Clear(id);
|
playerPresence.Clear(id);
|
||||||
|
|
||||||
return Ok(new { player.Id, player.BannedUntil });
|
return Ok(new { player.Id, player.BannedUntil });
|
||||||
@@ -242,7 +246,7 @@ public class AdminController(
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
|
|
||||||
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
|
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||||
playerPresence.Clear(id);
|
playerPresence.Clear(id);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -974,7 +978,8 @@ public class AdminController(
|
|||||||
double? HotScore,
|
double? HotScore,
|
||||||
int? State,
|
int? State,
|
||||||
string? ImageName,
|
string? ImageName,
|
||||||
int? MaxCapacity);
|
int? MaxCapacity,
|
||||||
|
bool? IsAGRoom);
|
||||||
|
|
||||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
|
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
|
||||||
/// update of room properties. Only the non-null fields in the
|
/// update of room properties. Only the non-null fields in the
|
||||||
@@ -1004,6 +1009,20 @@ public class AdminController(
|
|||||||
room.MaxCapacity = Math.Clamp(cap, 1, 80);
|
room.MaxCapacity = Math.Clamp(cap, 1, 80);
|
||||||
changes.Add($"maxCapacity={room.MaxCapacity}");
|
changes.Add($"maxCapacity={room.MaxCapacity}");
|
||||||
}
|
}
|
||||||
|
if (body.IsAGRoom is bool ag)
|
||||||
|
{
|
||||||
|
// IsAGRoom is what the client reads as IsRRO. True: load the
|
||||||
|
// baked scene from the client's own assets AND consult the
|
||||||
|
// client-baked AGRoomRuntimeConfig table (which, per original,
|
||||||
|
// decides e.g. whether creation is allowed — Rec Center yes, Rec
|
||||||
|
// Royale no). False: treat the room as a custom room (roles and
|
||||||
|
// blob decide everything). Flipping a CLONE of a baked original
|
||||||
|
// to false is the server-side experiment for "editable Rec
|
||||||
|
// Royale"; whether the client still loads the baked scene for a
|
||||||
|
// non-original is what that experiment answers.
|
||||||
|
room.IsAGRoom = ag;
|
||||||
|
changes.Add($"isAGRoom={ag}");
|
||||||
|
}
|
||||||
|
|
||||||
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
|
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
|
||||||
|
|
||||||
@@ -1117,6 +1136,36 @@ public class AdminController(
|
|||||||
.OrderBy(s => s.OrderIndex)
|
.OrderBy(s => s.OrderIndex)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id);
|
||||||
|
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
|
||||||
|
|
||||||
|
// Seeded baked originals (Rec Royale, …) have NO RoomScenes rows at
|
||||||
|
// all; the game-client details builder synthesises a "Home" entry
|
||||||
|
// scene for them (RoomsController.BuildSubRoomWire with a null scene)
|
||||||
|
// and the mutation handlers create the row on first write. Mirror
|
||||||
|
// that here so the SPA has something to act on — the blob/enable
|
||||||
|
// action below creates the real row.
|
||||||
|
if (scenes.Count == 0)
|
||||||
|
{
|
||||||
|
return Ok(new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
id = "0",
|
||||||
|
subRoomId = 0,
|
||||||
|
name = "Home",
|
||||||
|
maxPlayers = room.MaxCapacity,
|
||||||
|
canMatchmakeInto = true,
|
||||||
|
isSandbox = false,
|
||||||
|
dataBlobName = string.Empty,
|
||||||
|
effectiveBlobName = EffectiveSubRoomBlobName(room, null, bakedOriginal),
|
||||||
|
isBakedOriginal = bakedOriginal,
|
||||||
|
isVirtual = true,
|
||||||
|
dataModifiedAt = room.UpdatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(scenes.Select(s => new
|
return Ok(scenes.Select(s => new
|
||||||
{
|
{
|
||||||
// Ids go out as strings: the admin SPA is JavaScript and silently
|
// Ids go out as strings: the admin SPA is JavaScript and silently
|
||||||
@@ -1128,10 +1177,260 @@ public class AdminController(
|
|||||||
canMatchmakeInto = s.CanMatchmakeInto,
|
canMatchmakeInto = s.CanMatchmakeInto,
|
||||||
isSandbox = s.IsSandbox,
|
isSandbox = s.IsSandbox,
|
||||||
dataBlobName = s.DataBlobName,
|
dataBlobName = s.DataBlobName,
|
||||||
|
// What the client will actually be told to download for this
|
||||||
|
// sub-room (same rule as RoomsController.BuildSubRoomWire): the
|
||||||
|
// scene's own blob, else the room's, else a synthetic default —
|
||||||
|
// except baked Rec Room Originals, which get NO blob until an
|
||||||
|
// admin enables one (see EnableSubRoomBlob).
|
||||||
|
effectiveBlobName = EffectiveSubRoomBlobName(room, s, bakedOriginal),
|
||||||
|
isBakedOriginal = bakedOriginal,
|
||||||
dataModifiedAt = s.DataModifiedAt,
|
dataModifiedAt = s.DataModifiedAt,
|
||||||
}).ToList());
|
}).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string EffectiveSubRoomBlobName(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(scene?.DataBlobName)) return scene!.DataBlobName;
|
||||||
|
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
|
||||||
|
if (bakedOriginal) return string.Empty;
|
||||||
|
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Blob name handed to a baked RRO sub-room when an admin enables
|
||||||
|
/// editing: the synthetic default for the entry scene (the same name the
|
||||||
|
/// CDN already recognises as "serve the RRO-editable blob on S3 miss"),
|
||||||
|
/// and the clone-style <c>_sub{n}</c> form for the others.</summary>
|
||||||
|
private static string SyntheticSubRoomBlobName(long roomId, int subRoomId) =>
|
||||||
|
subRoomId == 0
|
||||||
|
? RoomService.SyntheticDefaultRoomDataBlobName(roomId)
|
||||||
|
: $"room_{roomId}_dorknet_v8_sub{subRoomId}.dat";
|
||||||
|
|
||||||
|
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable</c>
|
||||||
|
/// — give a baked Rec Room Original sub-room a data blob so the client
|
||||||
|
/// runs its room-data path there (Maker Pen, CV2 chips, room saves).
|
||||||
|
///
|
||||||
|
/// Why this exists: baked RRO rooms (Rec Royale, Paintball, …) ship with
|
||||||
|
/// an empty <see cref="RoomSceneEntity.DataBlobName"/>, and every details
|
||||||
|
/// builder + matchmaking resolver turns that into an empty
|
||||||
|
/// <c>DataBlob</c>, which the client treats as "no room data at all" —
|
||||||
|
/// no persistence views, no room-role overlay, no Maker Pen, no room
|
||||||
|
/// mood engine. Real Rec Room served these rooms a blob (Rec Royale's
|
||||||
|
/// game-rule chip <c>RecRoyaleSolos_GameRuleWrapperCircuit</c> lives in
|
||||||
|
/// it, not in the scene). Stamping the synthetic default name is enough:
|
||||||
|
/// every resolver returns it verbatim, and <c>CdnController</c> already
|
||||||
|
/// answers that name for AG rooms with the RRO-editable blob (permissive
|
||||||
|
/// room roles, no objects) when nothing has been saved yet. The first
|
||||||
|
/// in-game save then replaces the name with a real upload.
|
||||||
|
///
|
||||||
|
/// No-op when the sub-room already has a blob. Idempotent.</summary>
|
||||||
|
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/enable")]
|
||||||
|
public async Task<ActionResult> EnableSubRoomBlob(long id, int subRoomId)
|
||||||
|
{
|
||||||
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
if (room is null) return NotFound();
|
||||||
|
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
|
||||||
|
if (scene is null && subRoomId != 0) return NotFound();
|
||||||
|
|
||||||
|
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
|
||||||
|
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
|
||||||
|
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: false));
|
||||||
|
|
||||||
|
var blobName = SyntheticSubRoomBlobName(id, subRoomId);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (scene is null)
|
||||||
|
{
|
||||||
|
// Seeded baked originals carry no RoomScenes row; create the
|
||||||
|
// entry scene with the same defaults the game-side mutation
|
||||||
|
// path uses (RoomsController.GetOrCreateSceneForMutationAsync).
|
||||||
|
scene = new RoomSceneEntity
|
||||||
|
{
|
||||||
|
RoomId = room.Id,
|
||||||
|
OrderIndex = 0,
|
||||||
|
Name = "Home",
|
||||||
|
RoomSceneLocationId = room.LocationReplicationId,
|
||||||
|
MaxPlayers = room.MaxCapacity,
|
||||||
|
IsSandbox = false,
|
||||||
|
CanMatchmakeInto = true,
|
||||||
|
};
|
||||||
|
db.RoomScenes.Add(scene);
|
||||||
|
}
|
||||||
|
scene.DataBlobName = blobName;
|
||||||
|
scene.DataModifiedAt = now;
|
||||||
|
if (subRoomId == 0 && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
|
||||||
|
{
|
||||||
|
room.CurrentDataBlobName = blobName;
|
||||||
|
room.UpdatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LogAsync("enable_subroom_blob", "room", id, $"subRoom={subRoomId} blob={blobName}");
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset</c>
|
||||||
|
/// — clear the sub-room's data blob reference. For a baked Rec Room
|
||||||
|
/// Original that puts the room back to "scene only, no blob" (the escape
|
||||||
|
/// hatch if a save left the room unplayable); for any other room the
|
||||||
|
/// resolvers fall back to the synthetic default, i.e. an empty room.
|
||||||
|
/// Saved blobs stay in <c>RoomDataBlobs</c>; only the pointers move, so
|
||||||
|
/// the in-game restore list can bring an old save back.</summary>
|
||||||
|
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/reset")]
|
||||||
|
public async Task<ActionResult> ResetSubRoomBlob(long id, int subRoomId)
|
||||||
|
{
|
||||||
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
if (room is null) return NotFound();
|
||||||
|
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
|
||||||
|
if (scene is null && subRoomId != 0) return NotFound();
|
||||||
|
|
||||||
|
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
|
||||||
|
var previousScene = scene?.DataBlobName ?? string.Empty;
|
||||||
|
var previousRoom = room.CurrentDataBlobName;
|
||||||
|
var changed = false;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
|
||||||
|
{
|
||||||
|
scene.DataBlobName = string.Empty;
|
||||||
|
scene.DataModifiedAt = now;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (subRoomId == 0 && !string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
|
||||||
|
{
|
||||||
|
room.CurrentDataBlobName = string.Empty;
|
||||||
|
room.UpdatedAt = now;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
await LogAsync("reset_subroom_blob", "room", id,
|
||||||
|
$"subRoom={subRoomId} scene '{previousScene}' -> '' room '{previousRoom}' -> '{room.CurrentDataBlobName}'");
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload</c>
|
||||||
|
/// — multipart <c>file</c> = a real <c>PersistedRoomData</c> save (any
|
||||||
|
/// client version; <c>.binpb</c> / <c>.room</c> / <c>.dat</c>), optional
|
||||||
|
/// form/query <c>mode</c> = <c>full</c> (default) or <c>basics</c>.
|
||||||
|
/// Rewrites it for this room (activity id, 2023 version clamp, drops
|
||||||
|
/// output log / sub-room id / unknown fields; <c>basics</c> also keeps
|
||||||
|
/// only the game-role and game-rule objects and drops the circuit
|
||||||
|
/// payloads — see <see cref="RoomDataBlobService.PrepareUploadedRoomBlob"/>),
|
||||||
|
/// stores it under a fresh <c>room_{id}_upload_*.dat</c> name, records
|
||||||
|
/// the RoomDataBlobs row and points the sub-room (and, for sub-room 0,
|
||||||
|
/// the room) at it. The CDN's RRO role overlay and version clamp then
|
||||||
|
/// apply on serve exactly as for Rec Center's saved blob.</summary>
|
||||||
|
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/upload")]
|
||||||
|
[RequestSizeLimit(64 * 1024 * 1024)]
|
||||||
|
public async Task<ActionResult> UploadSubRoomBlob(long id, int subRoomId, IFormFile? file, [FromQuery] string? mode)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0) return BadRequest("missing file");
|
||||||
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
if (room is null) return NotFound();
|
||||||
|
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
|
||||||
|
if (scene is null && subRoomId != 0) return NotFound();
|
||||||
|
|
||||||
|
byte[] raw;
|
||||||
|
using (var ms = new MemoryStream((int)file.Length))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(ms);
|
||||||
|
raw = ms.ToArray();
|
||||||
|
}
|
||||||
|
try { _ = PersistedRoomData.Parser.ParseFrom(raw); }
|
||||||
|
catch (Exception ex) { return BadRequest($"not a PersistedRoomData blob: {ex.Message}"); }
|
||||||
|
|
||||||
|
var basics = string.Equals(mode ?? Request.Form["mode"].ToString(), "basics", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var (bytes, summary) = RoomDataBlobService.PrepareUploadedRoomBlob(raw, room.LocationReplicationId, basics);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var blobName = $"room_{id}_upload_{now:yyyyMMddHHmmss}.dat";
|
||||||
|
var (bucket, key) = BlobRouter.Route(blobName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await storage.PutAsync(bucket, key, bytes, "application/octet-stream");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
adminLogger.LogError(ex, "[admin] blob upload S3 PUT failed room={Room} blob={Blob}", id, blobName);
|
||||||
|
return StatusCode(502, new { error = "storage_put_failed", detail = ex.Message });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||||
|
{
|
||||||
|
RoomId = id,
|
||||||
|
BlobName = blobName,
|
||||||
|
SubRoomId = subRoomId,
|
||||||
|
UploadedByPlayerId = CurrentAdminId,
|
||||||
|
UploadedAt = now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scene is null)
|
||||||
|
{
|
||||||
|
scene = new RoomSceneEntity
|
||||||
|
{
|
||||||
|
RoomId = room.Id,
|
||||||
|
OrderIndex = 0,
|
||||||
|
Name = "Home",
|
||||||
|
RoomSceneLocationId = room.LocationReplicationId,
|
||||||
|
MaxPlayers = room.MaxCapacity,
|
||||||
|
IsSandbox = false,
|
||||||
|
CanMatchmakeInto = true,
|
||||||
|
};
|
||||||
|
db.RoomScenes.Add(scene);
|
||||||
|
}
|
||||||
|
scene.DataBlobName = blobName;
|
||||||
|
scene.DataModifiedAt = now;
|
||||||
|
if (subRoomId == 0)
|
||||||
|
{
|
||||||
|
room.CurrentDataBlobName = blobName;
|
||||||
|
room.UpdatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LogAsync("upload_subroom_blob", "room", id,
|
||||||
|
$"subRoom={subRoomId} blob={blobName} mode={(basics ? "basics" : "full")} bytes={bytes.Length} views={summary.ViewsKept}/{summary.ViewsIn} roles={summary.RoleViews} rules={summary.GameRuleViews} version={summary.VersionIn}->{summary.VersionOut}");
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
id = scene.Id.ToString(),
|
||||||
|
subRoomId = scene.OrderIndex,
|
||||||
|
name = scene.Name,
|
||||||
|
dataBlobName = scene.DataBlobName,
|
||||||
|
roomCurrentDataBlobName = room.CurrentDataBlobName,
|
||||||
|
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
|
||||||
|
isBakedOriginal = bakedOriginal,
|
||||||
|
bytes = bytes.Length,
|
||||||
|
// Explicit camelCase: the API's naming policy is "as declared",
|
||||||
|
// and the rest of this payload is camelCase.
|
||||||
|
summary = new
|
||||||
|
{
|
||||||
|
viewsIn = summary.ViewsIn,
|
||||||
|
viewsKept = summary.ViewsKept,
|
||||||
|
roleViews = summary.RoleViews,
|
||||||
|
gameRuleViews = summary.GameRuleViews,
|
||||||
|
versionIn = summary.VersionIn,
|
||||||
|
versionOut = summary.VersionOut,
|
||||||
|
circuitsStripped = summary.CircuitsStripped,
|
||||||
|
activityId = summary.ActivityId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal, bool changed) => new
|
||||||
|
{
|
||||||
|
id = (scene?.Id ?? 0).ToString(),
|
||||||
|
subRoomId = scene?.OrderIndex ?? 0,
|
||||||
|
name = scene?.Name ?? "Home",
|
||||||
|
dataBlobName = scene?.DataBlobName ?? string.Empty,
|
||||||
|
roomCurrentDataBlobName = room.CurrentDataBlobName,
|
||||||
|
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
|
||||||
|
isBakedOriginal = bakedOriginal,
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
|
||||||
public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers);
|
public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers);
|
||||||
|
|
||||||
/// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c>
|
/// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c>
|
||||||
@@ -1274,10 +1573,13 @@ public class AdminController(
|
|||||||
return Ok(new { ok = true });
|
return Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Push fresh RoomDetails after role/owner mutations. The
|
/// <summary>Push fresh room details after role/owner mutations, to everyone
|
||||||
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts,
|
/// in the room plus the player whose role changed. The client caches the
|
||||||
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom
|
/// room's Roles and role-gated UI reads that cache. The 2023 "RoomUpdate"
|
||||||
/// stores a replacement.</summary>
|
/// handler (IBEOONPEELF.txt:507) reads the FULL room (FGCPNAACHIK) — the
|
||||||
|
/// shape GET roomserver/rooms/{id} returns. The 2020 {Room, Scenes}
|
||||||
|
/// wrapper parsed as RoomId 0 with no roles, so role changes never
|
||||||
|
/// reached anyone until they re-entered the room.</summary>
|
||||||
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
|
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
|
||||||
{
|
{
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
@@ -1291,7 +1593,7 @@ public class AdminController(
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
|
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
|
||||||
.BuildRoomDetails(room, scenes, roles: roles);
|
.BuildRoomServerDetails(room, scenes, roles: roles);
|
||||||
|
|
||||||
var recipients = onlinePresence.OnlinePlayerIds()
|
var recipients = onlinePresence.OnlinePlayerIds()
|
||||||
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
|
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
|
||||||
@@ -1443,7 +1745,7 @@ public class AdminController(
|
|||||||
{
|
{
|
||||||
var r = playerPresence.GetRoom(pid);
|
var r = playerPresence.GetRoom(pid);
|
||||||
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
|
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
|
||||||
await notifications.KickPlayerAsync(pid, reason);
|
await notifications.KickPlayerAsync(pid, reason, gameSessionId: instanceId);
|
||||||
playerPresence.Clear(pid);
|
playerPresence.Clear(pid);
|
||||||
kicked.Add(pid);
|
kicked.Add(pid);
|
||||||
}
|
}
|
||||||
@@ -1959,6 +2261,22 @@ public class AdminController(
|
|||||||
return Ok(settings);
|
return Ok(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>GET <c>api/admin/v1/settings/circuits-limits</c> — the
|
||||||
|
/// CircuitsV2 creation limits served as the <c>UGC.CircuitsV2.Limit.*</c>
|
||||||
|
/// gameconfig keys, including the per-frame RPC and byte budgets.</summary>
|
||||||
|
[HttpGet("settings/circuits-limits")]
|
||||||
|
public async Task<ActionResult> GetCircuitsV2Limits()
|
||||||
|
=> Ok(await serverSettings.GetCircuitsV2LimitsAsync());
|
||||||
|
|
||||||
|
[HttpPost("settings/circuits-limits")]
|
||||||
|
public async Task<ActionResult> SetCircuitsV2Limits([FromBody] CircuitsV2LimitSettings body)
|
||||||
|
{
|
||||||
|
var settings = await serverSettings.SetCircuitsV2LimitsAsync(body);
|
||||||
|
await LogAsync("gameconfigs_updated", "system", 0, "circuitsv2-limits");
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Ok(settings);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Signup codes ─────────────────────────────────────────────────────
|
// ── Signup codes ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
|
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
|
||||||
@@ -2084,7 +2402,9 @@ public class AdminController(
|
|||||||
var reason = string.IsNullOrWhiteSpace(body?.Reason)
|
var reason = string.IsNullOrWhiteSpace(body?.Reason)
|
||||||
? "Kicked by an admin."
|
? "Kicked by an admin."
|
||||||
: body!.Reason!.Trim();
|
: body!.Reason!.Trim();
|
||||||
await notifications.KickPlayerAsync(id, reason);
|
// The room instance lets the client announce the kick to that room.
|
||||||
|
await notifications.KickPlayerAsync(id, reason,
|
||||||
|
gameSessionId: playerPresence.GetRoom(id)?.RoomInstanceId ?? 0);
|
||||||
playerPresence.Clear(id);
|
playerPresence.Clear(id);
|
||||||
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
|
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -3016,10 +3336,11 @@ public class AdminController(
|
|||||||
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
|
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
|
||||||
await LogAsync("set_password", "player", id, "");
|
await LogAsync("set_password", "player", id, "");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
// Push a logout so any active sessions on the old password get
|
// Log out any session still on the old password; the client has to
|
||||||
// dropped — the watch re-authenticates with the new password.
|
// sign in again with the new one. The reason must be the numeric
|
||||||
await notifications.NotifyAsync(id, PushNotificationId.Logout,
|
// enum: the string "AdminPasswordReset" failed the client's parse and
|
||||||
new { Reason = "AdminPasswordReset" });
|
// the push was dropped.
|
||||||
|
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||||
return Ok(new { id });
|
return Ok(new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3188,22 +3509,26 @@ public class AdminController(
|
|||||||
|
|
||||||
playerPresence.SetRoom(id, roomInstance);
|
playerPresence.SetRoom(id, roomInstance);
|
||||||
|
|
||||||
// Two pushes back-to-back: GameSession update changes the
|
// The move itself comes from the presence set above: the client's
|
||||||
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance
|
// next heartbeat sees it is out of sync and joins that instance.
|
||||||
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom
|
// RoomInstanceUpdate only refreshes the client's cached copy of the
|
||||||
// refreshes the watch's RoomDetails cache so the room-info
|
// instance, and RoomUpdate its cached room details, which must be
|
||||||
// panel doesn't show stale data after the warp.
|
// the full room (FGCPNAACHIK) the roomserver GET returns.
|
||||||
await notifications.NotifyAsync(id,
|
await notifications.NotifyAsync(id,
|
||||||
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
|
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
|
||||||
|
var forceJoinScenes = await db.RoomScenes
|
||||||
|
.Where(sc => sc.RoomId == room.Id)
|
||||||
|
.OrderBy(sc => sc.OrderIndex)
|
||||||
|
.ToListAsync();
|
||||||
|
// With every role grant: the client replaces its cached room with
|
||||||
|
// this, so a payload without them would drop everyone's roles.
|
||||||
|
var forceJoinRoles = await db.RoomRoles
|
||||||
|
.Where(r => r.RoomId == room.Id)
|
||||||
|
.ToListAsync();
|
||||||
await notifications.NotifyAsync(id,
|
await notifications.NotifyAsync(id,
|
||||||
PushNotificationId.SubscriptionUpdateRoom,
|
PushNotificationId.SubscriptionUpdateRoom,
|
||||||
new
|
DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(
|
||||||
{
|
room, forceJoinScenes, roles: forceJoinRoles));
|
||||||
Room = new { Id = room.Id, Name = room.Name },
|
|
||||||
RoomId = room.Id,
|
|
||||||
SubRoomId = subRoomId,
|
|
||||||
Reason = "AdminForceJoin",
|
|
||||||
});
|
|
||||||
|
|
||||||
await LogAsync("force_join", "player", id,
|
await LogAsync("force_join", "player", id,
|
||||||
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
|
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
|
||||||
@@ -3220,20 +3545,46 @@ public class AdminController(
|
|||||||
|
|
||||||
// ── System ───────────────────────────────────────────────────────────
|
// ── System ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public sealed record SetMotdRequest(string Message);
|
public sealed record SetMotdRequest(string? Message, int? StartsInMinutes);
|
||||||
|
|
||||||
/// <summary>Push a server-maintenance broadcast to every connected
|
/// <summary>Broadcast to every connected player.
|
||||||
/// player. The MOTD config value is changed via appsettings.json
|
/// <list type="bullet">
|
||||||
/// (server restart required); this is for ad-hoc announcements.</summary>
|
/// <item><c>Message</c>: a coach message (MessageReceived, Type 100, from
|
||||||
|
/// the system account) in each online player's notifications —
|
||||||
|
/// the client's channel for free text from the server.</item>
|
||||||
|
/// <item><c>StartsInMinutes</c>: the client's own maintenance countdown
|
||||||
|
/// (ServerMaintenance "25", which reads only
|
||||||
|
/// <c>StartsInMinutes</c>; 0 cancels a running countdown).</item>
|
||||||
|
/// </list>
|
||||||
|
/// ServerMaintenance used to carry the text as <c>{Message}</c>, which the
|
||||||
|
/// client never reads: the text was never shown, and StartsInMinutes 0
|
||||||
|
/// cancelled any countdown instead.</summary>
|
||||||
[HttpPost("broadcast")]
|
[HttpPost("broadcast")]
|
||||||
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
|
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message");
|
var message = body.Message?.Trim() ?? string.Empty;
|
||||||
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
|
if (message.Length == 0 && body.StartsInMinutes is null)
|
||||||
new { Message = body.Message });
|
return BadRequest("empty message");
|
||||||
await LogAsync("broadcast", "system", 0, body.Message);
|
if (body.StartsInMinutes is < 0)
|
||||||
|
return BadRequest("StartsInMinutes must be 0 or more");
|
||||||
|
|
||||||
|
var recipients = onlinePresence.OnlinePlayerIds().ToList();
|
||||||
|
if (message.Length > 0)
|
||||||
|
{
|
||||||
|
foreach (var pid in recipients)
|
||||||
|
await systemNotifications.SendAsync(pid,
|
||||||
|
SystemNotificationService.MessageType.CoachMessage,
|
||||||
|
fromPlayerId: PlayerService.SystemAccountId,
|
||||||
|
data: message);
|
||||||
|
}
|
||||||
|
if (body.StartsInMinutes is int minutes)
|
||||||
|
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
|
||||||
|
new { StartsInMinutes = minutes });
|
||||||
|
|
||||||
|
await LogAsync("broadcast", "system", 0,
|
||||||
|
body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok();
|
return Ok(new { recipients = recipients.Count });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Store catalog management ─────────────────────────────────────────
|
// ── Store catalog management ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
@@ -41,6 +42,27 @@ public class AdminLoginController(
|
|||||||
if (!player.IsAdmin)
|
if (!player.IsAdmin)
|
||||||
return StatusCode(403, new { error = "not_admin" });
|
return StatusCode(403, new { error = "not_admin" });
|
||||||
|
|
||||||
|
return TokenResponse(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET api/admin/v1/sso — exchange a single sign-on session
|
||||||
|
/// (<see cref="ExternalSignIn.WebScheme"/>, signed in on this admin host via
|
||||||
|
/// the external provider) for the same token pair <c>login</c> returns.
|
||||||
|
/// Admin rights still come from the account's <c>IsAdmin</c>.</summary>
|
||||||
|
[HttpGet("sso")]
|
||||||
|
public async Task<ActionResult> Sso()
|
||||||
|
{
|
||||||
|
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
|
||||||
|
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
|
||||||
|
return Unauthorized(new { error = "not_signed_in" });
|
||||||
|
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
|
if (player is null) return Unauthorized(new { error = "not_signed_in" });
|
||||||
|
if (!player.IsAdmin) return StatusCode(403, new { error = "not_admin" });
|
||||||
|
return TokenResponse(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OkObjectResult TokenResponse(Data.Entities.PlayerEntity player)
|
||||||
|
{
|
||||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ public class AuthController(
|
|||||||
[FromForm] int platform = 0,
|
[FromForm] int platform = 0,
|
||||||
[FromForm] string? platformId = null,
|
[FromForm] string? platformId = null,
|
||||||
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
|
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
|
||||||
[FromForm] long? account_id = null)
|
[FromForm] long? account_id = null,
|
||||||
|
[FromForm] string? device_code = null,
|
||||||
|
[FromForm] string? login_token = null)
|
||||||
{
|
{
|
||||||
// The client mixes camelCase and snake_case parameter naming
|
// The client mixes camelCase and snake_case parameter naming
|
||||||
// depending on grant type — accept both and merge.
|
// depending on grant type — accept both and merge.
|
||||||
@@ -139,6 +141,55 @@ public class AuthController(
|
|||||||
return TokenOk(acc1, refr1);
|
return TokenOk(acc1, refr1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device-authorization poll (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451).
|
||||||
|
// The client keeps polling on HTTP 400 with error authorization_pending
|
||||||
|
// or slow_down (LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116); any other
|
||||||
|
// error ends the attempt. This grant used to fall through to the
|
||||||
|
// device-id fallback, which signed the polling device straight in as
|
||||||
|
// whatever account its device id mapped to — no approval involved.
|
||||||
|
if (string.Equals(grant_type, DeviceCodeGrantType, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var (status, approverId) = await DeviceAuthorizations.RedeemAsync(db, device_code, DateTime.UtcNow);
|
||||||
|
switch (status)
|
||||||
|
{
|
||||||
|
case DeviceAuthorizations.RedeemStatus.Pending:
|
||||||
|
return BadRequest(new { error = "authorization_pending" });
|
||||||
|
case DeviceAuthorizations.RedeemStatus.Expired:
|
||||||
|
return BadRequest(new { error = "expired_token" });
|
||||||
|
case DeviceAuthorizations.RedeemStatus.Invalid:
|
||||||
|
return BadRequest(new { error = "invalid_grant" });
|
||||||
|
}
|
||||||
|
var approver = await playerService.GetByIdAsync(approverId);
|
||||||
|
if (approver is null) return BadRequest(new { error = "invalid_grant" });
|
||||||
|
await TryAwardLoginXpAsync(approver.Id);
|
||||||
|
var (accD, refrD) = authService.GenerateTokenPair(approver.Id);
|
||||||
|
return TokenOk(accD, refrD);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot token from account/me/createlogintoken, redeemed with the
|
||||||
|
// account it was minted for (LBNJFPOLCDL.txt:7593-7606: grant_type,
|
||||||
|
// account_id, login_token). Also used to fall through to the device-id
|
||||||
|
// fallback, ignoring both account_id and the token.
|
||||||
|
if (string.Equals(grant_type, "login_token", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (account_id is not long tokenAccountId || string.IsNullOrWhiteSpace(login_token))
|
||||||
|
return BadRequest(new { error = "invalid_request", error_description = "invalid login_token" });
|
||||||
|
var prefix = login_token.Trim() + "|";
|
||||||
|
var row = await db.PlayerSettings.FirstOrDefaultAsync(s =>
|
||||||
|
s.PlayerId == tokenAccountId
|
||||||
|
&& s.Key == Accounts.AccountsController.RemoteLoginTokenKey
|
||||||
|
&& s.Value.StartsWith(prefix));
|
||||||
|
if (row is null
|
||||||
|
|| !DateTime.TryParse(row.Value[prefix.Length..], null,
|
||||||
|
System.Globalization.DateTimeStyles.RoundtripKind, out var tokenExpiry)
|
||||||
|
|| tokenExpiry < DateTime.UtcNow)
|
||||||
|
return BadRequest(new { error = "invalid_grant", error_description = "invalid login_token" });
|
||||||
|
db.PlayerSettings.Remove(row);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var (accL, refrL) = authService.GenerateTokenPair(tokenAccountId);
|
||||||
|
return TokenOk(accL, refrL);
|
||||||
|
}
|
||||||
|
|
||||||
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(refresh_token))
|
if (string.IsNullOrWhiteSpace(refresh_token))
|
||||||
@@ -364,26 +415,20 @@ public class AuthController(
|
|||||||
[HttpGet("/connect/deviceauthorization")]
|
[HttpGet("/connect/deviceauthorization")]
|
||||||
public async Task<IActionResult> DeviceAuthorization()
|
public async Task<IActionResult> DeviceAuthorization()
|
||||||
{
|
{
|
||||||
var deviceCode = Guid.NewGuid().ToString("N");
|
var created = await DeviceAuthorizations.CreateAsync(db, DateTime.UtcNow);
|
||||||
var userCode = deviceCode[..8].ToUpperInvariant();
|
|
||||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
|
||||||
{
|
|
||||||
PlayerId = RelationshipQueries.SystemAccountId,
|
|
||||||
Key = $"deviceauthorization:{deviceCode}",
|
|
||||||
Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}",
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
device_code = deviceCode,
|
device_code = created.DeviceCode,
|
||||||
user_code = userCode,
|
user_code = created.UserCode,
|
||||||
verification_uri = $"https://{domain.Sub("auth")}/device",
|
verification_uri = $"https://{domain.Sub("auth")}/device",
|
||||||
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}",
|
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={created.UserCode}",
|
||||||
expires_in = 900,
|
expires_in = created.ExpiresInSeconds,
|
||||||
interval = 5,
|
interval = 5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public const string DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code";
|
||||||
|
|
||||||
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
||||||
{
|
{
|
||||||
Platform = platform,
|
Platform = platform,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
using DorkNet.Server.Services;
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
namespace DorkNet.Server.Controllers.Cdn;
|
namespace DorkNet.Server.Controllers.Cdn;
|
||||||
@@ -557,11 +558,18 @@ public class CdnController(
|
|||||||
return new FileContentResult(TransparentPng, "image/png");
|
return new FileContentResult(TransparentPng, "image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await ShouldOverlayRroRoomRoleDataAsync(fileName))
|
var rroRoom = await ResolveRroRoomAsync(fileName);
|
||||||
|
if (rroRoom is not null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable role blob",
|
// Per-room stub: a real save's skeleton at the 2023 version
|
||||||
Request.Host.Host, fileName);
|
// ceiling with the room's own activity_id. The old generic stub
|
||||||
var rroBlob = roomDataBlob.GetRroEditableBlob();
|
// (version 19, roles only) was fetched and silently rejected by
|
||||||
|
// the client, which is why an enabled baked room still had no
|
||||||
|
// Maker Pen. See RoomDataBlobService.BuildRroEditableBlob.
|
||||||
|
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable blob (room {Room}, activity {Activity})",
|
||||||
|
Request.Host.Host, fileName, rroRoom.Id, rroRoom.LocationReplicationId);
|
||||||
|
var rroBlob = roomDataBlob.GetRroEditableBlob(rroRoom.LocationReplicationId, rroRoom.Name);
|
||||||
|
rroBlob = ClampForClientUnlessDisabled(rroBlob, fileName);
|
||||||
Response.Headers.CacheControl = "public, max-age=60";
|
Response.Headers.CacheControl = "public, max-age=60";
|
||||||
signatures.AddContentSignature(Response, rroBlob);
|
signatures.AddContentSignature(Response, rroBlob);
|
||||||
return new FileContentResult(rroBlob, "application/octet-stream");
|
return new FileContentResult(rroBlob, "application/octet-stream");
|
||||||
@@ -569,14 +577,34 @@ public class CdnController(
|
|||||||
|
|
||||||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
|
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
|
||||||
Request.Host.Host, fileName);
|
Request.Host.Host, fileName);
|
||||||
var fallbackBlob = roomDataBlob.GetDefaultBlob();
|
var fallbackBlob = ClampForClientUnlessDisabled(roomDataBlob.GetDefaultBlob(), fileName);
|
||||||
signatures.AddContentSignature(Response, fallbackBlob);
|
signatures.AddContentSignature(Response, fallbackBlob);
|
||||||
return new FileContentResult(fallbackBlob, "application/octet-stream");
|
return new FileContentResult(fallbackBlob, "application/octet-stream");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName)
|
/// <summary>Same 2023 version clamp the S3-hit path applies, for the
|
||||||
|
/// synthesised blobs on the miss path — a stub past the client's ceiling
|
||||||
|
/// is rejected exactly like a modern export would be.</summary>
|
||||||
|
private byte[] ClampForClientUnlessDisabled(byte[] bytes, string fileName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
if (!IsRoomDataName(fileName)) return bytes;
|
||||||
|
if (serverSettings.IsRoomBlobVersionClampDisabledAsync().GetAwaiter().GetResult()) return bytes;
|
||||||
|
var (clamped, changed) = RoomDataBlobService.ClampVersionsFor2023(bytes);
|
||||||
|
if (changed)
|
||||||
|
logger.LogInformation("[cdn] clamped synthesised blob versions to 2023 maxima file={File}", fileName);
|
||||||
|
return clamped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName) =>
|
||||||
|
await ResolveRroRoomAsync(fileName) is not null;
|
||||||
|
|
||||||
|
/// <summary>The AG/RRO room a blob name belongs to, or null when the name
|
||||||
|
/// is not an RRO room's (current blob, synthetic <c>room_{id}_*</c> form,
|
||||||
|
/// or a sub-room scene blob). Drives both the role overlay on S3 hits and
|
||||||
|
/// the per-room stub on misses.</summary>
|
||||||
|
private async Task<RoomEntity?> ResolveRroRoomAsync(string fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName)) return null;
|
||||||
|
|
||||||
// MakerPen across ALL Rec Room Originals: overlay the permissive
|
// MakerPen across ALL Rec Room Originals: overlay the permissive
|
||||||
// room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO
|
// room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO
|
||||||
@@ -587,9 +615,9 @@ public class CdnController(
|
|||||||
// blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable-
|
// blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable-
|
||||||
// RoleData) — the game-rule circuit data and GameRoleData are left
|
// RoleData) — the game-rule circuit data and GameRoleData are left
|
||||||
// intact — so quest rooms keep working, exactly as Stunt Runner does.
|
// intact — so quest rooms keep working, exactly as Stunt Runner does.
|
||||||
if (await db.Rooms.AsNoTracking().AnyAsync(r =>
|
var byCurrentBlob = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r =>
|
||||||
r.IsAGRoom && r.CurrentDataBlobName == fileName))
|
r.IsAGRoom && r.CurrentDataBlobName == fileName);
|
||||||
return true;
|
if (byCurrentBlob is not null) return byCurrentBlob;
|
||||||
|
|
||||||
// Synthetic/default room-data blob names for rooms with no saved blob:
|
// Synthetic/default room-data blob names for rooms with no saved blob:
|
||||||
// the CURRENT form is room_{id}_dorknet_v8.dat
|
// the CURRENT form is room_{id}_dorknet_v8.dat
|
||||||
@@ -603,18 +631,19 @@ public class CdnController(
|
|||||||
{
|
{
|
||||||
var rest = fileName["room_".Length..];
|
var rest = fileName["room_".Length..];
|
||||||
var underscore = rest.IndexOf('_');
|
var underscore = rest.IndexOf('_');
|
||||||
if (underscore > 0 &&
|
if (underscore > 0 && long.TryParse(rest[..underscore], out var roomId))
|
||||||
long.TryParse(rest[..underscore], out var roomId) &&
|
{
|
||||||
await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom))
|
var byId = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId && r.IsAGRoom);
|
||||||
return true;
|
if (byId is not null) return byId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await (
|
return await (
|
||||||
from scene in db.RoomScenes.AsNoTracking()
|
from scene in db.RoomScenes.AsNoTracking()
|
||||||
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
|
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
|
||||||
where room.IsAGRoom && scene.DataBlobName == fileName
|
where room.IsAGRoom && scene.DataBlobName == fileName
|
||||||
select scene.Id
|
select room
|
||||||
).AnyAsync();
|
).FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -460,6 +460,20 @@ public class ChatController(
|
|||||||
var removed = await db.ChatThreadMembers
|
var removed = await db.ChatThreadMembers
|
||||||
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
|
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
if (removed > 0)
|
||||||
|
{
|
||||||
|
// "PlayerLeftChat" is an Action<ChatMessage> (DLDKCILCKNA.txt:721);
|
||||||
|
// the handler only reads ChatThreadId and SenderPlayerId (the
|
||||||
|
// player who left) and drops them from that thread's members.
|
||||||
|
// Sent to the remaining members, whose member lists otherwise
|
||||||
|
// stayed stale, and to the leaver's own other sessions.
|
||||||
|
var thread = await EnsureThreadRowAsync(chatThreadId);
|
||||||
|
var left = new ChatMessageEntity { ThreadKey = chatThreadId, SenderPlayerId = Me, Body = string.Empty };
|
||||||
|
var recipients = await ThreadRecipientsAsync(chatThreadId);
|
||||||
|
recipients.Add(Me);
|
||||||
|
foreach (var rid in recipients.Distinct())
|
||||||
|
await notifications.NotifyTypedAsync(rid, "PlayerLeftChat", ToWireMessage(left, thread.Id));
|
||||||
|
}
|
||||||
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
|
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,11 +91,13 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
/// 2023 client sends the target club in a form-urlencoded
|
/// 2023 client sends the target club in a form-urlencoded
|
||||||
/// <c>clubId</c> field (boxed <c>Nullable<Int64></c> pushed through
|
/// <c>clubId</c> field (boxed <c>Nullable<Int64></c> pushed through
|
||||||
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> at <c>IKMMOCKDKAF.txt:16923-16927</c>)
|
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> at <c>IKMMOCKDKAF.txt:16923-16927</c>)
|
||||||
/// and parses NO response DTO — the issuing method is
|
/// — the issuing method is <c>LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>)</c>.
|
||||||
/// <c>LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>)</c>, the status-only
|
/// Despite the untyped promise the body IS parsed: both branches hand
|
||||||
/// promise type, so an empty 200 is the contract. Verb ordinal 3 (PUT)
|
/// the response to <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (:16858, :16943), the
|
||||||
/// is the HasValue branch of the same method at :16911; the null branch
|
/// <c>{success,error}</c> RecNetResult reader, so an empty 200 surfaced as
|
||||||
/// is the DELETE below (:16838).</summary>
|
/// a failure (<see cref="RecNetResults"/>). Verb ordinal 3 (PUT) is the
|
||||||
|
/// HasValue branch of the same method at :16911; the null branch is the
|
||||||
|
/// DELETE below (:16838).</summary>
|
||||||
[HttpPut("/club/home/me")]
|
[HttpPut("/club/home/me")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> HomeMeSet()
|
public async Task<IActionResult> HomeMeSet()
|
||||||
@@ -116,11 +118,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
clubId = fromQuery;
|
clubId = fromQuery;
|
||||||
if (clubId <= 0) return BadRequest(new { error = "missing_club_id" });
|
if (clubId <= 0) return BadRequest(new { error = "missing_club_id" });
|
||||||
if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound();
|
if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound();
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>DELETE <c>/club/home/me</c> — "Remove Home Clubhouse Room".
|
/// <summary>DELETE <c>/club/home/me</c> — "Remove Home Clubhouse Room".
|
||||||
/// No body, no response DTO (<c>IKMMOCKDKAF.txt:16830-16841</c>: route
|
/// No request body; RecNetResult reply (<c>IKMMOCKDKAF.txt:16830-16858</c>: route
|
||||||
/// literal, host 13, verb ordinal 4, error string "Failed to Remove Home
|
/// literal, host 13, verb ordinal 4, error string "Failed to Remove Home
|
||||||
/// Clubhouse Room"). Idempotent so a double-tap is harmless.</summary>
|
/// Clubhouse Room"). Idempotent so a double-tap is harmless.</summary>
|
||||||
[HttpDelete("/club/home/me")]
|
[HttpDelete("/club/home/me")]
|
||||||
@@ -128,7 +130,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
public async Task<IActionResult> HomeMeClear()
|
public async Task<IActionResult> HomeMeClear()
|
||||||
{
|
{
|
||||||
await clubs.ClearHomeClubAsync(Me);
|
await clubs.ClearHomeClubAsync(Me);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("/club/mine/member")]
|
[HttpGet("/club/mine/member")]
|
||||||
@@ -347,7 +349,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
{
|
{
|
||||||
var pid = Me;
|
var pid = Me;
|
||||||
await clubs.MarkAnnouncementReadAsync(pid, announcementId);
|
await clubs.MarkAnnouncementReadAsync(pid, announcementId);
|
||||||
return Ok(new { Read = true });
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Single-club reads ─────────────────────────────────────────────
|
// ── Single-club reads ─────────────────────────────────────────────
|
||||||
@@ -369,8 +371,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
/// the club NAME used only for the client-side confirmation copy and is
|
/// the club NAME used only for the client-side confirmation copy and is
|
||||||
/// never transmitted; the request is a bare DELETE with no body
|
/// never transmitted; the request is a bare DELETE with no body
|
||||||
/// (<c>IKMMOCKDKAF.txt:17107-17120</c>, verb ordinal 4, host 13, error
|
/// (<c>IKMMOCKDKAF.txt:17107-17120</c>, verb ordinal 4, host 13, error
|
||||||
/// string "Failed to Delete Club"). <c>LDGADANDBIO</c> is the status-only
|
/// string "Failed to Delete Club"). The promise is built by
|
||||||
/// promise so an empty 200 is the whole contract.
|
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult, :17137), so the
|
||||||
|
/// body is a <c>{success,error}</c> RecNetResult — an empty 200 made the
|
||||||
|
/// client report "Failed to Delete Club" after the club was gone. See
|
||||||
|
/// <see cref="RecNetResults"/>.
|
||||||
///
|
///
|
||||||
/// Owner-only, and a soft delete — see
|
/// Owner-only, and a soft delete — see
|
||||||
/// <see cref="ClubService.DisbandAsync"/>.</summary>
|
/// <see cref="ClubService.DisbandAsync"/>.</summary>
|
||||||
@@ -382,7 +387,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
{
|
{
|
||||||
var disbanded = await clubs.DisbandAsync(clubId, Me);
|
var disbanded = await clubs.DisbandAsync(clubId, Me);
|
||||||
if (disbanded is null) return NotFound();
|
if (disbanded is null) return NotFound();
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException) { return Forbid(); }
|
catch (UnauthorizedAccessException) { return Forbid(); }
|
||||||
}
|
}
|
||||||
@@ -1144,7 +1149,15 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
/// is reachable — see <see cref="ToWireMemberRequest"/>.</summary>
|
/// is reachable — see <see cref="ToWireMemberRequest"/>.</summary>
|
||||||
private static int RequestStatusFor(ClubMembershipEntity row) => 1;
|
private static int RequestStatusFor(ClubMembershipEntity row) => 1;
|
||||||
|
|
||||||
// ── Member mutations (void / IPromise) ───────────────────────────
|
// ── Member mutations ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every one of these goes through the client's shared club-write helper
|
||||||
|
// IKMMOCKDKAF.JNFEEDLCGHH ("Failed to " + action), which hands the
|
||||||
|
// response to BFDGEKKNAPB.PEFKBAEAOPD (IKMMOCKDKAF.txt:26696) — the
|
||||||
|
// RecNetResult reader. They are not status-only: an empty 200 parses to
|
||||||
|
// null and the client shows "Failed to request to join club" (etc.) after
|
||||||
|
// the server has applied the change. Success bodies are
|
||||||
|
// RecNetResults.Ok().
|
||||||
|
|
||||||
/// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller
|
/// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller
|
||||||
/// asks to join. For Open clubs we promote straight to Member; for
|
/// asks to join. For Open clubs we promote straight to Member; for
|
||||||
@@ -1169,7 +1182,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
};
|
};
|
||||||
if (perms == -1) return Forbid();
|
if (perms == -1) return Forbid();
|
||||||
await clubs.UpsertMembershipAsync(clubId, Me, perms);
|
await clubs.UpsertMembershipAsync(clubId, Me, perms);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>/club/{id}/members/directJoin</c> — same as
|
/// <summary>POST <c>/club/{id}/members/directJoin</c> — same as
|
||||||
@@ -1183,7 +1196,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
var club = await clubs.GetByIdAsync(clubId);
|
var club = await clubs.GetByIdAsync(clubId);
|
||||||
if (club is null) return NotFound();
|
if (club is null) return NotFound();
|
||||||
await clubs.UpsertMembershipAsync(clubId, Me, 0);
|
await clubs.UpsertMembershipAsync(clubId, Me, 0);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>/club/{id}/members/leave</c> — caller leaves
|
/// <summary>POST <c>/club/{id}/members/leave</c> — caller leaves
|
||||||
@@ -1198,7 +1211,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
if (club.CreatorPlayerId == Me)
|
if (club.CreatorPlayerId == Me)
|
||||||
return BadRequest(new { error = "owner_must_transfer_first" });
|
return BadRequest(new { error = "owner_must_transfer_first" });
|
||||||
await clubs.RemoveMembershipAsync(clubId, Me);
|
await clubs.RemoveMembershipAsync(clubId, Me);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>/club/{id}/members/invite</c> — caller invites
|
/// <summary>POST <c>/club/{id}/members/invite</c> — caller invites
|
||||||
@@ -1220,7 +1233,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||||
await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending
|
await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>/club/{id}/members/invitemembers</c> — bulk
|
/// <summary>POST <c>/club/{id}/members/invitemembers</c> — bulk
|
||||||
@@ -1241,7 +1254,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
|
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
|
||||||
foreach (var pid in ids)
|
foreach (var pid in ids)
|
||||||
await clubs.UpsertMembershipAsync(clubId, pid, 128);
|
await clubs.UpsertMembershipAsync(clubId, pid, 128);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>/club/{id}/members/acceptinvite</c> — caller
|
/// <summary>POST <c>/club/{id}/members/acceptinvite</c> — caller
|
||||||
@@ -1254,7 +1267,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
if (row is null) return NotFound();
|
if (row is null) return NotFound();
|
||||||
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" });
|
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" });
|
||||||
await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member
|
await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>/club/{id}/members/declineinvite</c> — caller
|
/// <summary>POST <c>/club/{id}/members/declineinvite</c> — caller
|
||||||
@@ -1264,7 +1277,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
public async Task<IActionResult> MemberDeclineInvite(long clubId)
|
public async Task<IActionResult> MemberDeclineInvite(long clubId)
|
||||||
{
|
{
|
||||||
await clubs.RemoveMembershipAsync(clubId, Me);
|
await clubs.RemoveMembershipAsync(clubId, Me);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class MemberTargetRequest
|
public sealed class MemberTargetRequest
|
||||||
@@ -1288,7 +1301,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
if (row is null) return NotFound();
|
if (row is null) return NotFound();
|
||||||
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" });
|
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" });
|
||||||
await clubs.UpsertMembershipAsync(clubId, target, 0);
|
await clubs.UpsertMembershipAsync(clubId, target, 0);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class BulkTargetRequest
|
public sealed class BulkTargetRequest
|
||||||
@@ -1309,7 +1322,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
if (row is null || (row.Permissions & 128) == 0) continue;
|
if (row is null || (row.Permissions & 128) == 0) continue;
|
||||||
await clubs.UpsertMembershipAsync(clubId, pid, 0);
|
await clubs.UpsertMembershipAsync(clubId, pid, 0);
|
||||||
}
|
}
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/club/{clubId:long}/members/denyrequest")]
|
[HttpPost("/club/{clubId:long}/members/denyrequest")]
|
||||||
@@ -1320,7 +1333,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||||
await clubs.RemoveMembershipAsync(clubId, target);
|
await clubs.RemoveMembershipAsync(clubId, target);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/club/{clubId:long}/members/denyrequests")]
|
[HttpPost("/club/{clubId:long}/members/denyrequests")]
|
||||||
@@ -1331,7 +1344,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
|
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
|
||||||
foreach (var pid in ids)
|
foreach (var pid in ids)
|
||||||
await clubs.RemoveMembershipAsync(clubId, pid);
|
await clubs.RemoveMembershipAsync(clubId, pid);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/club/{clubId:long}/members/remove")]
|
[HttpPost("/club/{clubId:long}/members/remove")]
|
||||||
@@ -1346,7 +1359,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
if (target == club.CreatorPlayerId)
|
if (target == club.CreatorPlayerId)
|
||||||
return BadRequest(new { error = "cannot_remove_owner" });
|
return BadRequest(new { error = "cannot_remove_owner" });
|
||||||
await clubs.RemoveMembershipAsync(clubId, target);
|
await clubs.RemoveMembershipAsync(clubId, target);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>/club/{id}/members/ban</c> — kick + prevent
|
/// <summary>POST <c>/club/{id}/members/ban</c> — kick + prevent
|
||||||
@@ -1364,7 +1377,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
if (target == club.CreatorPlayerId)
|
if (target == club.CreatorPlayerId)
|
||||||
return BadRequest(new { error = "cannot_ban_owner" });
|
return BadRequest(new { error = "cannot_ban_owner" });
|
||||||
await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker
|
await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/club/{clubId:long}/members/unban")]
|
[HttpPost("/club/{clubId:long}/members/unban")]
|
||||||
@@ -1375,7 +1388,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||||
await clubs.RemoveMembershipAsync(clubId, target);
|
await clubs.RemoveMembershipAsync(clubId, target);
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>/club/{id}/members/changetype</c> — promote /
|
/// <summary>POST <c>/club/{id}/members/changetype</c> — promote /
|
||||||
@@ -1396,7 +1409,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
if (target == club.CreatorPlayerId)
|
if (target == club.CreatorPlayerId)
|
||||||
return BadRequest(new { error = "cannot_change_owner_role" });
|
return BadRequest(new { error = "cannot_change_owner_role" });
|
||||||
await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType));
|
await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType));
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── members/bulk + clubreporting ─────────────────────────────────
|
// ── members/bulk + clubreporting ─────────────────────────────────
|
||||||
@@ -1513,7 +1526,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId)
|
public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId)
|
||||||
{
|
{
|
||||||
if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid();
|
if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid();
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>PUT <c>/announcements/club/{clubId}/{announcementId}</c> — EDIT
|
/// <summary>PUT <c>/announcements/club/{clubId}/{announcementId}</c> — EDIT
|
||||||
@@ -1529,7 +1542,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
|||||||
var (title, body, imageName) = await ReadAnnouncementFieldsAsync();
|
var (title, body, imageName) = await ReadAnnouncementFieldsAsync();
|
||||||
if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName))
|
if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
return Ok();
|
return RecNetResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>/announcements/club/{clubId}</c> — CREATE an
|
/// <summary>POST <c>/announcements/club/{clubId}</c> — CREATE an
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ public class GoToController(
|
|||||||
RoomService rooms,
|
RoomService rooms,
|
||||||
DorkNet.Server.Data.DorkNetDbContext db,
|
DorkNet.Server.Data.DorkNetDbContext db,
|
||||||
NotificationService notifications,
|
NotificationService notifications,
|
||||||
|
SystemNotificationService systemNotifications,
|
||||||
JoinTimeoutService joinTimeouts,
|
JoinTimeoutService joinTimeouts,
|
||||||
ServerSettingsService serverSettings,
|
ServerSettingsService serverSettings,
|
||||||
|
PresenceBroadcastService presenceFanout,
|
||||||
ILogger<GoToController> logger) : ControllerBase
|
ILogger<GoToController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("/goto/room/{roomName}")]
|
[HttpPost("/goto/room/{roomName}")]
|
||||||
@@ -315,6 +317,10 @@ public class GoToController(
|
|||||||
[FromForm(Name = "JoinMode")] int joinMode = 0,
|
[FromForm(Name = "JoinMode")] int joinMode = 0,
|
||||||
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
|
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
|
||||||
{
|
{
|
||||||
|
// The client sends the wire SubRoomId it read from the room details
|
||||||
|
// (OrderIndex + 1, see RoomSceneIds); everything below works in
|
||||||
|
// OrderIndex.
|
||||||
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId);
|
||||||
var room = await rooms.GetByIdAsync(roomId);
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
if (room is null)
|
if (room is null)
|
||||||
{
|
{
|
||||||
@@ -876,11 +882,15 @@ public class GoToController(
|
|||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
|
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
|
||||||
callerId, playerId, theirRoom.RoomInstanceId);
|
callerId, playerId, theirRoom.RoomInstanceId);
|
||||||
// Notify the OWNER that someone tried to follow them in.
|
// Tell the OWNER someone tried to follow them in, as the
|
||||||
// Helps surface the rejection so the owner knows there's
|
// client's own "wants to join" message (RequestGameInvite,
|
||||||
// an interested party — they can choose to send an invite.
|
// from the caller), so they can send an invite. At most once
|
||||||
await notifications.PrivateInstanceJoinRejected(
|
// per requester, owner and instance per cooldown, so retrying
|
||||||
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0);
|
// doesn't fill the owner's inbox.
|
||||||
|
if (callerId is long requester
|
||||||
|
&& JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId))
|
||||||
|
await systemNotifications.SendAsync(playerId,
|
||||||
|
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
|
||||||
return Ok(new MatchmakingResponseDto
|
return Ok(new MatchmakingResponseDto
|
||||||
{
|
{
|
||||||
ErrorCode = 26, // RoomInstanceIsPrivate
|
ErrorCode = 26, // RoomInstanceIsPrivate
|
||||||
@@ -1294,15 +1304,14 @@ public class GoToController(
|
|||||||
|
|
||||||
private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room)
|
private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room)
|
||||||
{
|
{
|
||||||
// Honors the global-friends toggle: with it on, every other player is
|
// Honors the global-friends toggle (every other player is a "friend"
|
||||||
// a "friend" and gets the presence ping (so the whole small server
|
// when it is on). Goes through the shared fan-out so the frame
|
||||||
// sees each other's room moves).
|
// carries the player's real status visibility and the same
|
||||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, playerId);
|
// online/room state every other presence transition reports.
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)",
|
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName}",
|
||||||
playerId, room.RoomId, room.Name, friendIds.Count);
|
playerId, room.RoomId, room.Name);
|
||||||
if (friendIds.Count == 0) return;
|
await presenceFanout.PublishCurrentAsync(playerId);
|
||||||
await notifications.PlayerEnteredRoom(friendIds, playerId, room);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<MatchmakingResponseDto> BuildResponseAsync(string roomName)
|
private async Task<MatchmakingResponseDto> BuildResponseAsync(string roomName)
|
||||||
@@ -1547,9 +1556,25 @@ public class RoomInstanceDto
|
|||||||
[JsonPropertyName("roomId")]
|
[JsonPropertyName("roomId")]
|
||||||
public long RoomId { get; set; }
|
public long RoomId { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("subRoomId")]
|
/// <summary>The sub-room's <see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>
|
||||||
|
/// (0 = the room's first sub-room). Server code reads and writes this;
|
||||||
|
/// the wire carries <see cref="WireSubRoomId"/>.</summary>
|
||||||
|
[JsonIgnore]
|
||||||
public long SubRoomId { get; set; }
|
public long SubRoomId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The 2023 client's SubRoomId: OrderIndex + 1 (see
|
||||||
|
/// <see cref="Services.RoomSceneIds"/>). It must equal the
|
||||||
|
/// <c>SubRooms[].SubRoomId</c> the room details advertise — the client
|
||||||
|
/// compares the two (<c>SubroomModel.get_SubroomIsLocal</c>) and looks the
|
||||||
|
/// current sub-room up by it. Presence round-trips through this property
|
||||||
|
/// too, so stored JSON stays consistent.</summary>
|
||||||
|
[JsonPropertyName("subRoomId")]
|
||||||
|
public long WireSubRoomId
|
||||||
|
{
|
||||||
|
get => Services.RoomSceneIds.ToWire(SubRoomId);
|
||||||
|
set => SubRoomId = Services.RoomSceneIds.ToOrderIndex(value);
|
||||||
|
}
|
||||||
|
|
||||||
// STRING RoomSceneLocationId GUID — foreign-keys to
|
// STRING RoomSceneLocationId GUID — foreign-keys to
|
||||||
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
|
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
|
||||||
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
|
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
|
||||||
@@ -1668,3 +1693,35 @@ public class RoomInstanceDto
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Rate limit for "wants to join" messages: one per (requester,
|
||||||
|
/// owner, room instance) per <see cref="Window"/>. Later retries and other
|
||||||
|
/// instances still go through. Per replica, which is enough to stop a
|
||||||
|
/// retry loop from flooding an inbox.</summary>
|
||||||
|
internal static class JoinRequestCooldown
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
|
||||||
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new();
|
||||||
|
|
||||||
|
/// <summary>True when no request for this key was sent within the window
|
||||||
|
/// (and records this one); false while it is cooling down.</summary>
|
||||||
|
public static bool TryStart(long requester, long owner, long roomInstanceId)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (Last.Count > 10_000)
|
||||||
|
foreach (var (key, at) in Last)
|
||||||
|
if (now - at > Window) Last.TryRemove(key, out _);
|
||||||
|
|
||||||
|
var k = (requester, owner, roomInstanceId);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (!Last.TryGetValue(k, out var prev))
|
||||||
|
{
|
||||||
|
if (Last.TryAdd(k, now)) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (now - prev < Window) return false;
|
||||||
|
if (Last.TryUpdate(k, now, prev)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class MatchController(
|
|||||||
PlayerPresenceService presence,
|
PlayerPresenceService presence,
|
||||||
PrivateInstanceService privateInstances,
|
PrivateInstanceService privateInstances,
|
||||||
JoinTimeoutService joinTimeouts,
|
JoinTimeoutService joinTimeouts,
|
||||||
|
PresenceBroadcastService presenceFanout,
|
||||||
RoomService rooms,
|
RoomService rooms,
|
||||||
DorkNetDbContext db,
|
DorkNetDbContext db,
|
||||||
ILogger<MatchController> logger) : ControllerBase
|
ILogger<MatchController> logger) : ControllerBase
|
||||||
@@ -67,6 +68,12 @@ public class MatchController(
|
|||||||
completed.Value.TargetRoom.Name,
|
completed.Value.TargetRoom.Name,
|
||||||
completed.Value.TargetRoom.RoomInstanceId,
|
completed.Value.TargetRoom.RoomInstanceId,
|
||||||
completed.Value.DeferPresenceCommit);
|
completed.Value.DeferPresenceCommit);
|
||||||
|
// A deferred join (matchmake/dorm, private-instance hop)
|
||||||
|
// commits HERE, not at matchmake time, so this is the only
|
||||||
|
// point friends can learn about the move. Immediate commits
|
||||||
|
// were already announced by /goto.
|
||||||
|
if (completed.Value.DeferPresenceCommit)
|
||||||
|
await presenceFanout.PublishCurrentAsync(pid);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -88,99 +95,93 @@ public class MatchController(
|
|||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>POST <c>matchmake/none</c> — "leave room". The 2023 client's
|
||||||
|
/// only caller is TryGoToOfflineRoom
|
||||||
|
/// (RecNet.Runtime/RecNet/Matchmaking.txt:12677-13279): it has already
|
||||||
|
/// switched its own cached presence to the local offline instance
|
||||||
|
/// (RoomInstanceId -1) before posting this, and it discards the reply
|
||||||
|
/// (<c><TryGoToOfflineRoom>b__119_0</c> returns a constant).
|
||||||
|
///
|
||||||
|
/// So the player is now online in no room, and that is what presence must
|
||||||
|
/// say. This used to re-commit the previous room (or the dorm) instead:
|
||||||
|
/// the next heartbeat then answered with a room while the client held the
|
||||||
|
/// offline instance, the client logged "presence heartbeat response
|
||||||
|
/// indicates local presence is out-of-sync", cancelled its offline load
|
||||||
|
/// ("OnPlayerPresenceUpdate is cancelling room load for -1") and chased the
|
||||||
|
/// server's room — and friends kept seeing the room the player had left.
|
||||||
|
///
|
||||||
|
/// A join still pending is abandoned too: the client only goes offline
|
||||||
|
/// after giving up on it (fallback chain after a Photon timeout), and
|
||||||
|
/// echoing its target from the heartbeat would pull the player back.</summary>
|
||||||
[HttpPost("/matchmake/none")]
|
[HttpPost("/matchmake/none")]
|
||||||
public async Task<IActionResult> MatchmakeNone()
|
public async Task<IActionResult> MatchmakeNone()
|
||||||
{
|
{
|
||||||
var pid = this.CurrentPlayerId() ?? 0;
|
var pid = this.CurrentPlayerId() ?? 0;
|
||||||
if (pid == 0) return Unauthorized();
|
if (pid == 0) return Unauthorized();
|
||||||
|
|
||||||
|
var previous = presence.GetRoom(pid);
|
||||||
var pending = joinTimeouts.GetPending(pid);
|
var pending = joinTimeouts.GetPending(pid);
|
||||||
if (pending is { DeferPresenceCommit: true })
|
joinTimeouts.Cancel(pid, "matchmake-none");
|
||||||
{
|
presence.SetNoRoom(pid);
|
||||||
presence.MarkActive(pid);
|
|
||||||
logger.LogInformation(
|
|
||||||
"[matchmake-none] player={Player} has deferred pending room={Room} instance={Instance}; not committing presence",
|
|
||||||
pid,
|
|
||||||
pending.Value.TargetRoom.RoomId,
|
|
||||||
pending.Value.TargetRoom.RoomInstanceId);
|
|
||||||
return Ok(new MatchmakingResponseDto
|
|
||||||
{
|
|
||||||
ErrorCode = 0,
|
|
||||||
RoomInstance = pending.Value.TargetRoom,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
|
|
||||||
presence.SetRoom(pid, current);
|
|
||||||
presence.MarkActive(pid);
|
presence.MarkActive(pid);
|
||||||
logger.LogInformation("[matchmake-none] player={Player}", pid);
|
presence.EndBootDormGrace(pid);
|
||||||
|
logger.LogInformation(
|
||||||
|
"[matchmake-none] player={Player} left room={Room} instance={Instance} abandonedPending={Pending}; online with no room",
|
||||||
|
pid, previous?.RoomId, previous?.RoomInstanceId, pending?.TargetRoom.RoomInstanceId);
|
||||||
|
|
||||||
|
await presenceFanout.PublishCurrentAsync(pid);
|
||||||
|
|
||||||
|
// The client ignores this body; keep a well-formed instance in it so
|
||||||
|
// the shared MatchmakingResponse reader never sees a null object.
|
||||||
return Ok(new MatchmakingResponseDto
|
return Ok(new MatchmakingResponseDto
|
||||||
{
|
{
|
||||||
ErrorCode = 0,
|
ErrorCode = 0,
|
||||||
RoomInstance = current,
|
RoomInstance = previous ?? pending?.TargetRoom ?? await BuildDormRoomInstanceAsync(pid),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>GET <c>photon_access_token</c> — read by the room-permissions
|
||||||
|
/// runtime as <c>ALIIIAGAPND {RoomInstanceId, PhotonAccessToken,
|
||||||
|
/// Permissions}</c> (NLDBPDCNNCF.CEOPCANHPPH, verb 0, issued from the
|
||||||
|
/// rooms client, hence the <c>roomserver/</c> twin).
|
||||||
|
/// <c>Permissions</c> is the role-permission matrix of the sub-room the
|
||||||
|
/// player is in: the defaults plus whatever the room's editors saved with
|
||||||
|
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>
|
||||||
|
/// (<see cref="RoomPermissionMatrix"/>). It must not be empty: with no
|
||||||
|
/// Permissions the client falls into its persisted-role migration path,
|
||||||
|
/// which NREs during first spawn.</summary>
|
||||||
[HttpGet("/photon_access_token")]
|
[HttpGet("/photon_access_token")]
|
||||||
public IActionResult PhotonAccessToken()
|
[HttpGet("/roomserver/photon_access_token")]
|
||||||
|
public async Task<IActionResult> PhotonAccessToken()
|
||||||
{
|
{
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
var appId = config["Photon:AppId"] ?? string.Empty;
|
var appId = config["Photon:AppId"] ?? string.Empty;
|
||||||
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
|
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
|
||||||
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
|
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
|
||||||
var currentRoom = presence.GetRoom(pid);
|
var currentRoom = presence.GetRoom(pid);
|
||||||
var permissions = BuildPhotonPermissionSeed();
|
var permissions = currentRoom is null
|
||||||
|
? RoomPermissionMatrix.Merge(Array.Empty<RoomRolePermissionEntity>())
|
||||||
|
: await RoomPermissionMatrix.LoadAsync(db, currentRoom.RoomId, currentRoom.SubRoomId);
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
|
"[photon-token] player={PlayerId} room={RoomId} sub={SubRoomId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
|
||||||
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length);
|
pid, currentRoom?.RoomId ?? 0L, currentRoom?.SubRoomId ?? 0L, currentRoom?.RoomInstanceId ?? 0L, permissions.Count);
|
||||||
return Ok(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
// 2023 deserializes this endpoint as ALIIIAGAPND before room
|
|
||||||
// permissions initialization. If Permissions is missing/empty,
|
|
||||||
// OMJKHJLFOCO falls into the persisted-role migration path that
|
|
||||||
// currently NREs during first spawn.
|
|
||||||
["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L,
|
|
||||||
["PhotonAccessToken"] = string.Empty,
|
|
||||||
["Permissions"] = permissions,
|
|
||||||
|
|
||||||
// Existing custom-auth/app-id shape kept for Photon auth callers.
|
var body = RoomPermissionMatrix.AccessTokenBody(currentRoom?.RoomInstanceId ?? 0L, permissions);
|
||||||
["ResultCode"] = 1,
|
// Existing custom-auth/app-id shape kept for Photon auth callers; the
|
||||||
["Message"] = string.Empty,
|
// ALIIIAGAPND reader ignores keys it does not register.
|
||||||
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
body["ResultCode"] = 1;
|
||||||
["PhotonAppId"] = appId,
|
body["Message"] = string.Empty;
|
||||||
["PhotonVoiceAppId"] = voiceAppId,
|
body["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
["AppId"] = appId,
|
body["PhotonAppId"] = appId;
|
||||||
["VoiceAppId"] = voiceAppId,
|
body["PhotonVoiceAppId"] = voiceAppId;
|
||||||
["CloudRegion"] = region,
|
body["AppId"] = appId;
|
||||||
["Region"] = region,
|
body["VoiceAppId"] = voiceAppId;
|
||||||
["Token"] = string.Empty,
|
body["CloudRegion"] = region;
|
||||||
});
|
body["Region"] = region;
|
||||||
|
body["Token"] = string.Empty;
|
||||||
|
return Ok(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object[] BuildPhotonPermissionSeed()
|
|
||||||
=>
|
|
||||||
[
|
|
||||||
BuildPhotonPermission(13, 30), // Can Use Maker Pen
|
|
||||||
BuildPhotonPermission(20, 30), // Can Spawn Inventions
|
|
||||||
BuildPhotonPermission(21, 30), // Can Spawn Consumables
|
|
||||||
BuildPhotonPermission(22, 30), // Can Use Room Reset Button
|
|
||||||
BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
|
|
||||||
BuildPhotonPermission(13, 255), // Creator alias
|
|
||||||
BuildPhotonPermission(20, 255),
|
|
||||||
BuildPhotonPermission(21, 255),
|
|
||||||
BuildPhotonPermission(22, 255),
|
|
||||||
BuildPhotonPermission(23, 255),
|
|
||||||
];
|
|
||||||
|
|
||||||
private static object BuildPhotonPermission(int permission, int role) => new
|
|
||||||
{
|
|
||||||
Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
||||||
Role = role,
|
|
||||||
Override = true,
|
|
||||||
Type = 0,
|
|
||||||
Value = "true",
|
|
||||||
};
|
|
||||||
|
|
||||||
[HttpPost("/matchmake/dorm")]
|
[HttpPost("/matchmake/dorm")]
|
||||||
public async Task<IActionResult> MatchmakeDorm()
|
public async Task<IActionResult> MatchmakeDorm()
|
||||||
{
|
{
|
||||||
@@ -471,97 +472,125 @@ public class MatchController(
|
|||||||
return Ok(session);
|
return Ok(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>GET <c>match.*/room/{id}/instances</c> — the
|
/// <summary>GET <c>match.*/room/{id}/instances</c> — the room instance
|
||||||
/// "RoomInstanceBrowser" the watch shows when you tap a room tile.
|
/// browser.
|
||||||
/// Returns the currently-active instances so the player can see
|
|
||||||
/// which one their friends are in (and join it) instead of being
|
|
||||||
/// matchmade into a fresh empty room. Verified URL at
|
|
||||||
/// Matchmaking.txt:6286; response is a
|
|
||||||
/// <c>List<SimpleRoomInstance></c> per
|
|
||||||
/// <c>SimpleRoomInstance.cs</c>: RoomInstanceId/RoomId/SubRoomId,
|
|
||||||
/// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
|
|
||||||
/// HasModPresent, HashedInstanceId.
|
|
||||||
///
|
///
|
||||||
/// We derive instances from PlayerPresenceService's per-player
|
/// <para><b>Client contract.</b> <c>RecNet.Matchmaking.KAIPGDGACGG(long)</c>
|
||||||
/// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId
|
/// (RecNet.Runtime/RecNet/Matchmaking.txt:13966, route at :14237, verb 0 =
|
||||||
/// is one instance, with the players currently presenced there.
|
/// GET on the matchmaking host) returns <c>List<PNDCMIMEJLD></c>, a bare
|
||||||
/// PrivateInstances are surfaced as well so the inviter and
|
/// array. The reader (JPNMBICAMEE.txt) takes exactly RoomInstanceId,
|
||||||
/// invitee see the same room in the browser.</summary>
|
/// RoomId, SubRoomId, IsFull, CreatedAt and PlayerIds; the post-parse hook
|
||||||
|
/// (PNDCMIMEJLD.FKDDCNLJOLF) derives PlayerCount from PlayerIds.Count,
|
||||||
|
/// SubroomName from the cached room details' SubRooms by SubRoomId, and
|
||||||
|
/// HasModPresent from the room's Roles. The screen
|
||||||
|
/// (RoomInstanceBrowserScreen / RoomInstancePagedGrid) shows every row it
|
||||||
|
/// is given, current instance first then oldest CreatedAt first, and
|
||||||
|
/// renders the age as UtcNow - CreatedAt, so CreatedAt must be UTC.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>What is listed.</b> Every live instance of the room: each
|
||||||
|
/// distinct Photon room in which at least one player is presenced with a
|
||||||
|
/// live activity marker (<see cref="PlayerPresenceService.EnumerateActiveInstances"/>).
|
||||||
|
/// Private instances are included only for callers allowed into them —
|
||||||
|
/// the room creator, accepted role holders, the instance owner, its
|
||||||
|
/// invitees, and players already inside — so another room's invite-only
|
||||||
|
/// match never leaks. Empty instances are not listed: there is nothing to
|
||||||
|
/// join and they are what made the old list stale.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Fixed defects.</b> The Redis presence path returned nothing at
|
||||||
|
/// all, so the browser was empty on every multi-replica deployment;
|
||||||
|
/// private instances (every user room is born private and has one
|
||||||
|
/// instance per sub-room) were dropped from presence and re-added only
|
||||||
|
/// for their owner with an empty PlayerIds, so they showed 0 players or
|
||||||
|
/// nothing; expired or crashed presences still counted; IsFull was always
|
||||||
|
/// false; and SubRoomId used the OrderIndex, which does not match the
|
||||||
|
/// room details' wire ids.</para></summary>
|
||||||
[HttpGet("/room/{roomId:long}/instances")]
|
[HttpGet("/room/{roomId:long}/instances")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> RoomInstances(long roomId)
|
public async Task<IActionResult> RoomInstances(long roomId)
|
||||||
{
|
{
|
||||||
var presenced = presence.EnumerateActiveInstances(roomId).ToList();
|
|
||||||
// Only surface the caller's OWN private instances — we never
|
|
||||||
// leak someone else's PhotonRoomId into the browser. The Photon
|
|
||||||
// join check still requires CanJoinAsync, but redacting at the
|
|
||||||
// list level matches the existing RoomInstanceDto.Redact policy.
|
|
||||||
var pid = this.CurrentPlayerId() ?? 0;
|
var pid = this.CurrentPlayerId() ?? 0;
|
||||||
var privates = pid == 0
|
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
? new List<Data.Entities.PrivateInstanceEntity>()
|
if (room is null) return Ok(Array.Empty<object>());
|
||||||
: await db.PrivateInstances
|
|
||||||
.Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
// Merge: presence rows are authoritative (player currently
|
var live = presence.EnumerateActiveInstances(roomId, includePrivate: true).ToList();
|
||||||
// there); add any owned private instance the inviter created
|
|
||||||
// but nobody's joined yet so the inviter can still see it.
|
var privateIds = live.Where(l => l.Room.IsPrivate).Select(l => l.Room.RoomInstanceId).Distinct().ToList();
|
||||||
var byKey = new Dictionary<string, SimpleInstance>(StringComparer.Ordinal);
|
var privateRows = privateIds.Count == 0
|
||||||
foreach (var (room, playerIds) in presenced)
|
? new Dictionary<long, PrivateInstanceEntity>()
|
||||||
|
: await db.PrivateInstances.AsNoTracking()
|
||||||
|
.Where(p => privateIds.Contains(p.Id))
|
||||||
|
.ToDictionaryAsync(p => p.Id);
|
||||||
|
|
||||||
|
var callerHasRole = pid != 0 && (room.CreatorPlayerId == pid
|
||||||
|
|| await db.RoomRoles.AsNoTracking().AnyAsync(r =>
|
||||||
|
r.RoomId == roomId && r.PlayerId == pid && r.Accepted));
|
||||||
|
var invitedTo = pid == 0 || privateIds.Count == 0
|
||||||
|
? new HashSet<long>()
|
||||||
|
: (await db.PrivateInstanceInvitees.AsNoTracking()
|
||||||
|
.Where(i => i.PlayerId == pid && privateIds.Contains(i.PrivateInstanceId))
|
||||||
|
.Select(i => i.PrivateInstanceId)
|
||||||
|
.ToListAsync()).ToHashSet();
|
||||||
|
|
||||||
|
var rows = new List<RoomInstanceRow>();
|
||||||
|
foreach (var (inst, playerIds) in live)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
|
privateRows.TryGetValue(inst.RoomInstanceId, out var privateRow);
|
||||||
byKey[room.PhotonRoomId] = new SimpleInstance(
|
if (inst.IsPrivate && !CanSeePrivateInstance(
|
||||||
RoomInstanceId: room.RoomInstanceId,
|
pid, callerHasRole, playerIds, privateRow, invitedTo.Contains(inst.RoomInstanceId)))
|
||||||
RoomId: room.RoomId,
|
continue;
|
||||||
SubRoomId: room.SubRoomId,
|
|
||||||
IsFull: false,
|
var capacity = inst.MaxCapacity > 0 ? inst.MaxCapacity : privateRow?.MaxCapacity ?? 0;
|
||||||
CreatedAt: DateTime.UtcNow,
|
rows.Add(new RoomInstanceRow(
|
||||||
PlayerIds: playerIds.Select(p => (int)p).ToList(),
|
RoomInstanceId: inst.RoomInstanceId,
|
||||||
SubroomName: room.Name ?? string.Empty,
|
RoomId: inst.RoomId,
|
||||||
PlayerCount: playerIds.Count,
|
SubRoomId: inst.SubRoomId,
|
||||||
HasModPresent: false,
|
IsFull: capacity > 0 && playerIds.Count >= capacity,
|
||||||
HashedInstanceId: room.PhotonRoomId);
|
CreatedAt: privateRow is not null
|
||||||
}
|
? DateTime.SpecifyKind(privateRow.CreatedAt, DateTimeKind.Utc)
|
||||||
foreach (var p in privates)
|
: DateTime.UtcNow,
|
||||||
{
|
PlayerIds: playerIds.Distinct().OrderBy(p => p).ToList()));
|
||||||
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
|
|
||||||
byKey[p.PhotonRoomId] = new SimpleInstance(
|
|
||||||
RoomInstanceId: p.Id,
|
|
||||||
RoomId: p.RoomId,
|
|
||||||
SubRoomId: p.SubRoomId,
|
|
||||||
IsFull: false,
|
|
||||||
CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
|
|
||||||
PlayerIds: new List<int>(),
|
|
||||||
SubroomName: p.Name,
|
|
||||||
PlayerCount: 0,
|
|
||||||
HasModPresent: false,
|
|
||||||
HashedInstanceId: p.PhotonRoomId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE
|
logger.LogInformation(
|
||||||
// keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt
|
"[room-instances] room={Room} caller={Caller} live={Live} listed={Listed}",
|
||||||
// lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull,
|
roomId, pid, live.Count, rows.Count);
|
||||||
// createdAt, playerIds}). SubroomName / PlayerCount /
|
return Ok(rows.OrderBy(r => r.CreatedAt).Select(ToWire));
|
||||||
// HasModPresent / HashedInstanceId are populated locally on the
|
|
||||||
// watch (PlayerIds.Count, local hash, etc.) — not read from
|
|
||||||
// JSON, so we don't send them. Earlier PascalCase response made
|
|
||||||
// every field default to 0/null on the watch, breaking the
|
|
||||||
// browser.
|
|
||||||
return Ok(byKey.Values.Select(i => new
|
|
||||||
{
|
|
||||||
roomInstanceId = i.RoomInstanceId,
|
|
||||||
roomId = i.RoomId,
|
|
||||||
subRoomId = i.SubRoomId,
|
|
||||||
isFull = i.IsFull,
|
|
||||||
createdAt = i.CreatedAt,
|
|
||||||
playerIds = i.PlayerIds.ToArray(),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record SimpleInstance(
|
/// <summary>Who may see (and so be offered a join into) a private
|
||||||
|
/// instance in the browser.</summary>
|
||||||
|
public static bool CanSeePrivateInstance(
|
||||||
|
long callerId,
|
||||||
|
bool callerHasRoomRole,
|
||||||
|
IReadOnlyCollection<long> playersInside,
|
||||||
|
PrivateInstanceEntity? privateRow,
|
||||||
|
bool callerInvited)
|
||||||
|
{
|
||||||
|
if (callerId == 0) return false;
|
||||||
|
if (callerHasRoomRole) return true;
|
||||||
|
if (playersInside.Contains(callerId)) return true;
|
||||||
|
if (privateRow is not null && privateRow.OwnerPlayerId == callerId) return true;
|
||||||
|
return callerInvited;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RoomInstanceRow(
|
||||||
long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
|
long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
|
||||||
DateTime CreatedAt, List<int> PlayerIds, string SubroomName,
|
DateTime CreatedAt, List<long> PlayerIds);
|
||||||
int PlayerCount, bool HasModPresent, string HashedInstanceId);
|
|
||||||
|
/// <summary>PNDCMIMEJLD wire row. Account ids are Int32 on this DTO;
|
||||||
|
/// SubRoomId is the wire id the room details advertise
|
||||||
|
/// (<see cref="RoomSceneIds"/>), which the client uses to name the row.
|
||||||
|
/// The reader matches PascalCase, camelCase and lowercase keys; camelCase
|
||||||
|
/// is what this route has always sent.</summary>
|
||||||
|
internal static object ToWire(RoomInstanceRow r) => new
|
||||||
|
{
|
||||||
|
roomInstanceId = r.RoomInstanceId,
|
||||||
|
roomId = r.RoomId,
|
||||||
|
subRoomId = RoomSceneIds.ToWire(r.SubRoomId),
|
||||||
|
isFull = r.IsFull,
|
||||||
|
createdAt = r.CreatedAt,
|
||||||
|
playerIds = r.PlayerIds.Select(p => (int)p).ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs
|
/// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs
|
||||||
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
|
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class MatchPlayerController(
|
|||||||
RoomService rooms,
|
RoomService rooms,
|
||||||
PrivateInstanceService privateInstances,
|
PrivateInstanceService privateInstances,
|
||||||
JoinTimeoutService joinTimeouts,
|
JoinTimeoutService joinTimeouts,
|
||||||
|
PresenceBroadcastService presenceFanout,
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
DorkNetDbContext db,
|
DorkNetDbContext db,
|
||||||
ILogger<MatchPlayerController> log) : ControllerBase
|
ILogger<MatchPlayerController> log) : ControllerBase
|
||||||
@@ -68,17 +69,15 @@ public class MatchPlayerController(
|
|||||||
if (replaced)
|
if (replaced)
|
||||||
{
|
{
|
||||||
log.LogWarning(
|
log.LogWarning(
|
||||||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
|
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out",
|
||||||
logScope, playerId, takeOverExclusiveSession);
|
logScope, playerId, takeOverExclusiveSession);
|
||||||
// The new session hasn't opened its SignalR socket yet
|
// The new session hasn't opened its SignalR socket yet
|
||||||
// (negotiate fires AFTER /player/login on the watch's
|
// (negotiate fires AFTER /player/login on the boot
|
||||||
// boot sequence — verified in the live trace), so the
|
// sequence — verified in the live trace), so the Logout
|
||||||
// ModerationKick push hits only the OLDER session's
|
// push (Reason SessionTakeover) reaches only the OLDER
|
||||||
// existing connections. They'll show the "you've been
|
// session, which shows the client's own "signed in
|
||||||
// kicked" dialog and gracefully disconnect via the
|
// elsewhere" message and returns to the boot scene.
|
||||||
// watch's LocalModerationKickSelf path.
|
await notifications.LogoutStaleSession(playerId);
|
||||||
await notifications.KickStaleSession(playerId,
|
|
||||||
"Your account was signed in on another device.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Ok(Array.Empty<object>());
|
return Ok(Array.Empty<object>());
|
||||||
@@ -87,13 +86,20 @@ public class MatchPlayerController(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// POST /player/logout — fires when the client gives up on a session
|
/// POST /player/logout — fires when the client gives up on a session
|
||||||
/// (e.g. Photon connection couldn't be established, client aborts).
|
/// (e.g. Photon connection couldn't be established, client aborts).
|
||||||
/// Clear the cached presence so the next /goto starts fresh.
|
/// Clear the cached presence so the next /goto starts fresh, and tell
|
||||||
|
/// friends right away — otherwise they keep seeing the player in their
|
||||||
|
/// last room until a bulk poll happens to notice.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/player/logout")]
|
[HttpPost("/player/logout")]
|
||||||
public IActionResult PlayerLogout()
|
public async Task<IActionResult> PlayerLogout()
|
||||||
{
|
{
|
||||||
var playerId = TryGetCurrentPlayerId();
|
var playerId = TryGetCurrentPlayerId();
|
||||||
if (playerId != 0) presence.Clear(playerId);
|
if (playerId != 0)
|
||||||
|
{
|
||||||
|
joinTimeouts.Cancel(playerId, "logout");
|
||||||
|
presence.Clear(playerId);
|
||||||
|
await presenceFanout.PublishOfflineAsync(playerId);
|
||||||
|
}
|
||||||
return Ok(new { success = true, error = "" });
|
return Ok(new { success = true, error = "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +184,15 @@ public class MatchPlayerController(
|
|||||||
public async Task<ActionResult<PresenceDto>> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value)
|
public async Task<ActionResult<PresenceDto>> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value)
|
||||||
{
|
{
|
||||||
var playerId = TryGetCurrentPlayerId();
|
var playerId = TryGetCurrentPlayerId();
|
||||||
if (value is int v) await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
|
if (value is int v)
|
||||||
|
{
|
||||||
|
await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
|
||||||
|
// Friends only. The client does not apply this response to its own
|
||||||
|
// cached presence, and a PresenceUpdate about the local player runs
|
||||||
|
// the same apply-and-rejoin path as an out-of-sync heartbeat
|
||||||
|
// (Matchmaking.EGPABEDPEJO -> FLAOOBNJAAD, Matchmaking.txt:6917), so no self push.
|
||||||
|
await presenceFanout.PublishCurrentAsync(playerId);
|
||||||
|
}
|
||||||
var room = await GetPresenceRoomForResponseAsync(playerId);
|
var room = await GetPresenceRoomForResponseAsync(playerId);
|
||||||
return Ok(new PresenceDto
|
return Ok(new PresenceDto
|
||||||
{
|
{
|
||||||
@@ -212,7 +226,7 @@ public class MatchPlayerController(
|
|||||||
// back in the response and was then dropped, so the setting reverted on the
|
// back in the response and was then dropped, so the setting reverted on the
|
||||||
// next heartbeat and never survived a restart. They live in PlayerSettings
|
// next heartbeat and never survived a restart. They live in PlayerSettings
|
||||||
// (no schema change needed) and are read back wherever presence is built.
|
// (no schema change needed) and are read back wherever presence is built.
|
||||||
private const string PrefStatusVisibility = "pref:statusVisibility";
|
private const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
|
||||||
private const string PrefVrMovementMode = "pref:vrMovementMode";
|
private const string PrefVrMovementMode = "pref:vrMovementMode";
|
||||||
private const string PrefAvoidJuniors = "pref:avoidJuniors";
|
private const string PrefAvoidJuniors = "pref:avoidJuniors";
|
||||||
|
|
||||||
@@ -395,6 +409,21 @@ public class MatchPlayerController(
|
|||||||
|
|
||||||
if (room is null)
|
if (room is null)
|
||||||
{
|
{
|
||||||
|
// The player went to the offline room (matchmake/none). Their
|
||||||
|
// client now holds RoomInstanceId -1, which the heartbeat compare
|
||||||
|
// treats as "no room" (HCHDEHIGEBE.GIICPDHJOOH, sign bit of the id;
|
||||||
|
// RecNet.Runtime/RecNet/Matchmaking.txt:18415-18437) and is only in
|
||||||
|
// sync with a null roomInstance. The dorm fallback below would
|
||||||
|
// read as out-of-sync and drag the player out of the offline room.
|
||||||
|
if (presence.IsInNoRoom(playerId))
|
||||||
|
{
|
||||||
|
presence.TouchNoRoom(playerId);
|
||||||
|
log.LogInformation(
|
||||||
|
"[player-heartbeat] player={PlayerId} online in no room (left via matchmake/none); returning null roomInstance",
|
||||||
|
playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (presence.IsBootDormGraceActive(playerId))
|
if (presence.IsBootDormGraceActive(playerId))
|
||||||
{
|
{
|
||||||
log.LogInformation(
|
log.LogInformation(
|
||||||
@@ -426,6 +455,7 @@ public class MatchPlayerController(
|
|||||||
playerId, fallback.RoomId, fallback.DataBlob);
|
playerId, fallback.RoomId, fallback.DataBlob);
|
||||||
presence.SetRoom(playerId, fallback);
|
presence.SetRoom(playerId, fallback);
|
||||||
presence.MarkActive(playerId);
|
presence.MarkActive(playerId);
|
||||||
|
await presenceFanout.PublishCurrentAsync(playerId);
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId);
|
log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using DorkNet.Server.Auth;
|
using DorkNet.Server.Auth;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
using DorkNet.Server.Services;
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
namespace DorkNet.Server.Controllers.Match;
|
namespace DorkNet.Server.Controllers.Match;
|
||||||
@@ -36,10 +38,19 @@ namespace DorkNet.Server.Controllers.Match;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public class PlayerPresenceController(
|
public class PlayerPresenceController(
|
||||||
PlayerPresenceService presence) : ControllerBase
|
PlayerPresenceService presence,
|
||||||
|
PresenceBroadcastService presenceFanout,
|
||||||
|
JoinTimeoutService joinTimeouts,
|
||||||
|
DorkNetDbContext db,
|
||||||
|
ServerSettingsService settings) : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>PlayerSettings key written by <c>PUT /player/statusvisibility</c>
|
||||||
|
/// (MatchPlayerController.PrefStatusVisibility — same literal; the
|
||||||
|
/// round-trip is pinned by a regression test).</summary>
|
||||||
|
public const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
|
||||||
|
|
||||||
[HttpGet("/player")]
|
[HttpGet("/player")]
|
||||||
public ActionResult<List<PlayerPresenceDto>> GetPresences()
|
public async Task<ActionResult<List<PlayerPresenceDto>>> GetPresences()
|
||||||
{
|
{
|
||||||
// The watch sends two URL flavours for this endpoint:
|
// The watch sends two URL flavours for this endpoint:
|
||||||
// 1. Bulk: <c>/player?{id1}&{id2}&{id3}</c> — bare keys,
|
// 1. Bulk: <c>/player?{id1}&{id2}&{id3}</c> — bare keys,
|
||||||
@@ -81,31 +92,39 @@ public class PlayerPresenceController(
|
|||||||
// mid-coroutine. Only redact for OTHER players where the
|
// mid-coroutine. Only redact for OTHER players where the
|
||||||
// privacy concern actually applies.
|
// privacy concern actually applies.
|
||||||
var callerId = this.CurrentPlayerId();
|
var callerId = this.CurrentPlayerId();
|
||||||
var callerRoom = callerId is long cid ? presence.GetRoom(cid) : null;
|
var callerRoom = callerId is long cid ? SelfRoom(cid) : null;
|
||||||
|
var visibility = await LoadVisibilityAsync(ids);
|
||||||
|
var hiddenFromCaller = await ResolveHiddenAsync(callerId, ids, visibility);
|
||||||
var result = ids.Select(id =>
|
var result = ids.Select(id =>
|
||||||
{
|
{
|
||||||
var room = presence.GetRoom(id);
|
|
||||||
// Other players need both a room presence and an active
|
|
||||||
// live signal. Use the recent-heartbeat marker as the
|
|
||||||
// truth source: SignalR can drop while the player is still
|
|
||||||
// heartbeating, or linger after the game is gone. Presence
|
|
||||||
// keys intentionally live for minutes to survive long room
|
|
||||||
// loads, so using them alone leaves friends stuck online
|
|
||||||
// after a graceful disconnect.
|
|
||||||
var isSelf = callerId == id;
|
var isSelf = callerId == id;
|
||||||
var active = isSelf || presence.IsRecentlyActive(id);
|
// The caller's own row must say exactly what their heartbeat
|
||||||
var isOnline = room is not null && active;
|
// says (pending-join target, or null in the offline room): the
|
||||||
|
// client compares its cached self presence field by field
|
||||||
|
// (HCHDEHIGEBE.DCIJHIPJHOB) and treats any difference as
|
||||||
|
// out-of-sync.
|
||||||
|
var room = isSelf ? callerRoom : presence.GetRoom(id);
|
||||||
|
// Online = a live signal (recent heartbeat, or a SignalR
|
||||||
|
// connection), NOT "has a room". Presence keys live for minutes
|
||||||
|
// to survive long room loads, so a room alone would leave friends
|
||||||
|
// stuck online after the game is gone; and a player in the offline
|
||||||
|
// room after matchmake/none has no room but is very much online
|
||||||
|
// (the 2023 DTO carries isOnline on its own; see
|
||||||
|
// NotificationService.BuildPresenceUpdatePayload).
|
||||||
|
// A player who chose "appear offline" (or friends/favorites
|
||||||
|
// only, when the caller doesn't qualify) is reported exactly
|
||||||
|
// like a genuinely offline player: no room, isOnline=false.
|
||||||
|
var isOnline = (isSelf || presenceFanout.IsOnline(id)) && !hiddenFromCaller.Contains(id);
|
||||||
|
if (!isOnline) room = null;
|
||||||
return new PlayerPresenceDto
|
return new PlayerPresenceDto
|
||||||
{
|
{
|
||||||
PlayerId = (int)id,
|
PlayerId = (int)id,
|
||||||
// StatusVisibility is the player's *preference* for who may
|
// The player's stored online-status visibility. Self MUST
|
||||||
// see them online. The enum is Everyone=0 / FriendsOnly=1 /
|
// see its own stored value: the profile dialog's current
|
||||||
// Nobody=2 — so the previous value 1 (mislabeled "Visible")
|
// selection is read from the cached self presence, and a
|
||||||
// actually meant FriendsOnly, and anyone who wasn't your
|
// hard-coded 0 here snapped it back to Public after every
|
||||||
// friend saw you as OFFLINE. On a private server everyone
|
// presence refresh.
|
||||||
// should see everyone, so report Everyone (0). IsOnline=false
|
StatusVisibility = visibility.GetValueOrDefault(id, PresenceVisibility.Public),
|
||||||
// still gates the "Online" badge for offline players.
|
|
||||||
StatusVisibility = 0, // Everyone
|
|
||||||
DeviceClass = 0, // Desktop / unknown — not VR
|
DeviceClass = 0, // Desktop / unknown — not VR
|
||||||
VrMovementMode = 0,
|
VrMovementMode = 0,
|
||||||
// Full instance details are safe, and necessary, when the
|
// Full instance details are safe, and necessary, when the
|
||||||
@@ -124,6 +143,58 @@ public class PlayerPresenceController(
|
|||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The caller's own room as their heartbeat reports it.</summary>
|
||||||
|
private RoomInstanceDto? SelfRoom(long playerId)
|
||||||
|
{
|
||||||
|
if (joinTimeouts.GetPending(playerId) is { DeferPresenceCommit: true } pending)
|
||||||
|
return pending.TargetRoom;
|
||||||
|
if (presence.IsInNoRoom(playerId)) return null;
|
||||||
|
return presence.GetRoom(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<Dictionary<long, int>> LoadVisibilityAsync(HashSet<long> ids) =>
|
||||||
|
PresenceBroadcastService.GetStatusVisibilitiesAsync(db, ids);
|
||||||
|
|
||||||
|
/// <summary>Ids whose online status the caller may not see. The caller
|
||||||
|
/// always sees itself; anonymous callers only see Public players.</summary>
|
||||||
|
private async Task<HashSet<long>> ResolveHiddenAsync(
|
||||||
|
long? callerId, HashSet<long> ids, Dictionary<long, int> visibility)
|
||||||
|
{
|
||||||
|
var hidden = new HashSet<long>();
|
||||||
|
var restricted = visibility
|
||||||
|
.Where(kv => kv.Value != PresenceVisibility.Public && kv.Key != callerId)
|
||||||
|
.ToList();
|
||||||
|
if (restricted.Count == 0) return hidden;
|
||||||
|
|
||||||
|
if (callerId is not long me)
|
||||||
|
{
|
||||||
|
foreach (var kv in restricted) hidden.Add(kv.Key);
|
||||||
|
return hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<long>? friends = null;
|
||||||
|
HashSet<long>? favoritedMe = null;
|
||||||
|
foreach (var (target, vis) in restricted)
|
||||||
|
{
|
||||||
|
if (vis == PresenceVisibility.Offline) { hidden.Add(target); continue; }
|
||||||
|
|
||||||
|
friends ??= (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, me)).ToHashSet();
|
||||||
|
if (!friends.Contains(target)) { hidden.Add(target); continue; }
|
||||||
|
|
||||||
|
if (vis == PresenceVisibility.FavoriteFriendsOnly)
|
||||||
|
{
|
||||||
|
// Favorited lives on the favoriter's own row
|
||||||
|
// (RequesterId = favoriter), as RelationshipsController.BuildMerged reads it.
|
||||||
|
favoritedMe ??= (await db.Relationships
|
||||||
|
.Where(r => r.TargetId == me && r.Favorited && ids.Contains(r.RequesterId))
|
||||||
|
.Select(r => r.RequesterId)
|
||||||
|
.ToListAsync()).ToHashSet();
|
||||||
|
if (!favoritedMe.Contains(target)) hidden.Add(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hidden;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ShouldExposeFullRoom(
|
private static bool ShouldExposeFullRoom(
|
||||||
long? callerId,
|
long? callerId,
|
||||||
RoomInstanceDto? callerRoom,
|
RoomInstanceDto? callerRoom,
|
||||||
@@ -151,8 +222,8 @@ public class PlayerPresenceDto
|
|||||||
[JsonPropertyName("playerId")]
|
[JsonPropertyName("playerId")]
|
||||||
public int PlayerId { get; set; }
|
public int PlayerId { get; set; }
|
||||||
|
|
||||||
/// <summary>PlayerStatusVisibility enum (int):
|
/// <summary>Online-status visibility enum (int, 2023 client):
|
||||||
/// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly.</summary>
|
/// 0 = Public, 1 = FriendsOnly, 2 = FavoriteFriendsOnly, 3 = Offline.</summary>
|
||||||
[JsonPropertyName("statusVisibility")]
|
[JsonPropertyName("statusVisibility")]
|
||||||
public int StatusVisibility { get; set; }
|
public int StatusVisibility { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps an action's reply in the legacy <c>{success, value, error}</c>
|
||||||
|
/// envelope the 2023 rooms client reads for every room and playlist mutation.
|
||||||
|
///
|
||||||
|
/// <para><b>Client contract.</b> All of these calls go through one generic
|
||||||
|
/// helper, <c>NLDBPDCNNCF.BDBHCEGKGDC<TResult>(string opName, HTTPMethods,
|
||||||
|
/// string route, …)</c> (RVA 0x35E50A0, recnet-runtime-decomp/NLDBPDCNNCF.cs:2310;
|
||||||
|
/// called as <c>Call 0x1835E50A0</c> 47 times in
|
||||||
|
/// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt). It is the same helper, with the
|
||||||
|
/// same <c>BDBHCEGKGDC<FGCPNAACHIK></c> instantiation token
|
||||||
|
/// <c>[0x1884C1948]</c>, that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
|
||||||
|
/// clone is proven in game to need <c>{success, value, error}</c>
|
||||||
|
/// (docs/recroom-2023-room-save.md). A bare room-details body has no
|
||||||
|
/// <c>success</c>, so the helper rejects it with no message and the player sees
|
||||||
|
/// <c>Exception of type 'CEMNLBKJABA' was thrown.</c> even though the server
|
||||||
|
/// applied the change. The routes are: rooms/{0}/{accessibility,
|
||||||
|
/// allow_new_users, automute, cloning, comments, creator, description, image,
|
||||||
|
/// loadscreen, max_player_calculation_mode, min_level, modify, name,
|
||||||
|
/// promo_external, promo_images, restrictions, tags, voice_chat_encryption,
|
||||||
|
/// warning}, rooms/{0}/roles/{1}[/invite], rooms/{0}/subrooms[/{1}[/accessibility,
|
||||||
|
/// clone, maxplayers, modify, move, name, permissions, publish_save]],
|
||||||
|
/// playlists and playlists/{0}/{accessibility, description, image, levelvoting,
|
||||||
|
/// name, restrictions, rooms/{1}, tags, warning}.</para>
|
||||||
|
///
|
||||||
|
/// <para>A 2xx object result becomes <c>{success:true, value, error:""}</c>.
|
||||||
|
/// Anything else becomes HTTP 200 <c>{success:false, value:null, error}</c>
|
||||||
|
/// with a message the client can show, because a non-2xx reply also surfaces
|
||||||
|
/// only as the message-less CEMNLBKJABA.</para>
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||||
|
public sealed class RecNetEnvelopeAttribute : ResultFilterAttribute
|
||||||
|
{
|
||||||
|
public override void OnResultExecuting(ResultExecutingContext context)
|
||||||
|
{
|
||||||
|
context.Result = Wrap(context.Result);
|
||||||
|
base.OnResultExecuting(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IActionResult Wrap(IActionResult result)
|
||||||
|
{
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case ObjectResult obj when obj.StatusCode is null or (>= 200 and < 300):
|
||||||
|
return IsEnvelope(obj.Value)
|
||||||
|
? result
|
||||||
|
: Success(obj.Value);
|
||||||
|
case ObjectResult obj:
|
||||||
|
return Failure(ErrorText(obj.Value) ?? DefaultError(obj.StatusCode ?? 500));
|
||||||
|
case ForbidResult or ChallengeResult or UnauthorizedResult:
|
||||||
|
return Failure(DefaultError(403));
|
||||||
|
case StatusCodeResult code when code.StatusCode is >= 200 and < 300:
|
||||||
|
return Success(null);
|
||||||
|
case StatusCodeResult code:
|
||||||
|
return Failure(DefaultError(code.StatusCode));
|
||||||
|
case EmptyResult:
|
||||||
|
return Success(null);
|
||||||
|
default:
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OkObjectResult Success(object? value) =>
|
||||||
|
new(new { success = true, value, error = string.Empty });
|
||||||
|
|
||||||
|
public static OkObjectResult Failure(string error) =>
|
||||||
|
new(new { success = false, value = (object?)null, error });
|
||||||
|
|
||||||
|
private static string DefaultError(int status) => status switch
|
||||||
|
{
|
||||||
|
400 => "That request was not valid.",
|
||||||
|
401 or 403 => "You don't have permission to do that.",
|
||||||
|
404 => "That room doesn't exist.",
|
||||||
|
409 => "That name is already taken.",
|
||||||
|
_ => "Something went wrong.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>An <c>error</c>/<c>Error</c> string on the failure body, if any.</summary>
|
||||||
|
private static string? ErrorText(object? value)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
if (value is string s) return string.IsNullOrWhiteSpace(s) ? null : s;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value));
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||||
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (!prop.Name.Equals("error", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
if (prop.Value.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var text = prop.Value.GetString();
|
||||||
|
return string.IsNullOrWhiteSpace(text) ? null : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEnvelope(object? value)
|
||||||
|
{
|
||||||
|
if (value is null) return false;
|
||||||
|
var props = value.GetType().GetProperties();
|
||||||
|
bool Has(string name) => props.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return Has("success") && Has("value") && Has("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using DorkNet.Models.Auth;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bodies for the 2023 client's <c>RecNetResult</c> writes.
|
||||||
|
///
|
||||||
|
/// A lot of the client's fire-and-forget writes look status-only from their
|
||||||
|
/// signature (they return <c>LDGADANDBIO</c>, the untyped promise), but they
|
||||||
|
/// are not: they go through <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
|
||||||
|
/// (<c>RecNet.Runtime/BFDGEKKNAPB.txt:353</c>, "ExpandRecNetResult"), which
|
||||||
|
/// deserialises the body as <c>DJMHAFPGLLN</c> = <c>{Success, Error}</c>
|
||||||
|
/// (formatter <c>RecNet.Runtime/LMCCNLLHLCJ.txt:191-218</c> registers
|
||||||
|
/// <c>Success</c>/<c>success</c> and <c>Error</c>/<c>error</c>) and then runs
|
||||||
|
/// the <c><ExpandRecNetResult>b__2_0</c> lambda
|
||||||
|
/// (<c>RecNet.Runtime/BFDGEKKNAPB_NestedType___c.txt:61</c>):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>body null (an empty 200 or <c>null</c> — <c>COLNKALCBOK.txt</c>
|
||||||
|
/// JIKCMNMKNPH returns default for both) → NullReferenceException, the write
|
||||||
|
/// shows "Failed to …" although the server applied it;</item>
|
||||||
|
/// <item><c>Success</c> missing/false → <c>Promise.Rejected(Error)</c>; with no
|
||||||
|
/// <c>Error</c> key that throws <c>ArgumentException: Error message cannot be
|
||||||
|
/// null</c> (seen in Player.log as an unobserved task exception).</item>
|
||||||
|
/// </list>
|
||||||
|
/// So every such endpoint must answer <c>{"success":true,"error":""}</c> on
|
||||||
|
/// success, and carry a non-null <c>error</c> on a 200 failure.
|
||||||
|
/// </summary>
|
||||||
|
public static class RecNetResults
|
||||||
|
{
|
||||||
|
public static IActionResult Ok() =>
|
||||||
|
new OkObjectResult(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
|
||||||
|
public static IActionResult Fail(string error) =>
|
||||||
|
new OkObjectResult(new RecNetResult { Success = false, Error = error });
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers.Site;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The client builds CI publishes, for the site's Downloads page and the
|
||||||
|
/// Quest page's "latest build" install. Files are streamed from the downloads
|
||||||
|
/// bucket (<see cref="DownloadsService"/>) with Range support, so the browser
|
||||||
|
/// installer can read an .xapk's zip directory and entries without fetching
|
||||||
|
/// the whole file. Only files the manifest lists are served.
|
||||||
|
///
|
||||||
|
/// The builds contain Rec Room's own game files, so the routes answer only on
|
||||||
|
/// the site's own host (the apex) and 404 on every other host the server
|
||||||
|
/// accepts: the game's API/CDN subdomains are typically left open for the
|
||||||
|
/// client, while the site sits behind an access proxy (e.g. Cloudflare Access).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/site/v1/downloads")]
|
||||||
|
public class DownloadsController(DownloadsService downloads, DomainConfig domain) : ControllerBase
|
||||||
|
{
|
||||||
|
private bool Available => downloads.IsConfigured
|
||||||
|
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>With <see cref="DownloadsService.RequireSignIn"/>, only a
|
||||||
|
/// signed-in, linked player may read the builds.</summary>
|
||||||
|
private async Task<bool> SignedInOrOpenAsync() =>
|
||||||
|
!downloads.RequireSignIn
|
||||||
|
|| (await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme)).Succeeded;
|
||||||
|
|
||||||
|
/// <summary>GET /api/site/v1/downloads — the manifest as published
|
||||||
|
/// (snake_case keys), or 404 when downloads aren't set up or the request
|
||||||
|
/// isn't for the site's host.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetManifest(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
|
||||||
|
if (!await SignedInOrOpenAsync()) return Unauthorized(new { error = "sign_in_required" });
|
||||||
|
var manifest = await downloads.GetManifestAsync(ct);
|
||||||
|
Response.Headers.CacheControl = "no-cache";
|
||||||
|
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
|
||||||
|
.ToJsonString(), "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET /api/site/v1/downloads/files/{path} — one listed file,
|
||||||
|
/// streamed; honours Range (206).</summary>
|
||||||
|
[HttpGet("files/{**path}")]
|
||||||
|
public async Task GetFile(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
||||||
|
if (!await SignedInOrOpenAsync()) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
|
||||||
|
var manifest = await downloads.GetManifestAsync(ct);
|
||||||
|
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
|
||||||
|
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
||||||
|
|
||||||
|
using var upstream = await downloads.GetFileAsync(path, Request.Headers.Range.ToString(), ct);
|
||||||
|
Response.StatusCode = (int)upstream.StatusCode;
|
||||||
|
if (!upstream.IsSuccessStatusCode) return;
|
||||||
|
|
||||||
|
var content = upstream.Content.Headers;
|
||||||
|
if (content.ContentLength is long length) Response.ContentLength = length;
|
||||||
|
if (content.ContentRange is not null) Response.Headers.ContentRange = content.ContentRange.ToString();
|
||||||
|
Response.Headers.AcceptRanges = "bytes";
|
||||||
|
if (upstream.Headers.ETag is not null) Response.Headers.ETag = upstream.Headers.ETag.ToString();
|
||||||
|
Response.Headers.CacheControl = "private, no-cache";
|
||||||
|
Response.ContentType = "application/octet-stream";
|
||||||
|
var name = (string?)entry["name"] ?? Path.GetFileName(path);
|
||||||
|
Response.Headers.ContentDisposition = new System.Net.Mime.ContentDisposition { FileName = name }.ToString();
|
||||||
|
|
||||||
|
await using var body = await upstream.Content.ReadAsStreamAsync(ct);
|
||||||
|
await body.CopyToAsync(Response.Body, 1 << 16, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers.Site;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sign-in with the external OpenID Connect provider (<see cref="ExternalSignInOptions"/>)
|
||||||
|
/// for the website and the admin panel. The game client's login is separate.
|
||||||
|
///
|
||||||
|
/// <para>Flow: <c>login</c> challenges the provider; its callback
|
||||||
|
/// (<see cref="ExternalSignIn.CallbackPath"/>, handled by the OpenID Connect
|
||||||
|
/// middleware) stores the provider identity in the short-lived
|
||||||
|
/// <c>DorkNet.External</c> cookie and lands on <c>complete</c>. A linked identity
|
||||||
|
/// becomes a <c>DorkNet.Web</c> session at once; an unlinked one goes to the
|
||||||
|
/// website's <c>/link</c> page, where the player proves the account with its
|
||||||
|
/// password once (<c>account/link</c>).</para>
|
||||||
|
///
|
||||||
|
/// <para>Each host signs in on its own (website and admin panel), because the
|
||||||
|
/// hyphen host style puts them under different cookie domains.</para>
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public class ExternalSignInController(
|
||||||
|
IOptions<ExternalSignInOptions> options,
|
||||||
|
ExternalSignInService signIn,
|
||||||
|
LinkAttemptLimiter limiter,
|
||||||
|
DomainConfig domain,
|
||||||
|
ILogger<ExternalSignInController> log) : ControllerBase
|
||||||
|
{
|
||||||
|
private ExternalSignInOptions Options => options.Value;
|
||||||
|
|
||||||
|
/// <summary>GET /api/auth/external/login?returnUrl=/path — start sign-in.</summary>
|
||||||
|
[HttpGet(ExternalSignIn.LoginPath)]
|
||||||
|
public IActionResult Login([FromQuery] string? returnUrl)
|
||||||
|
{
|
||||||
|
if (!Options.IsConfigured) return NotFound(new { error = "sign_in_not_configured" });
|
||||||
|
var complete = $"{ExternalSignIn.CompletePath}?returnUrl={Uri.EscapeDataString(LocalUrl(returnUrl))}";
|
||||||
|
return Challenge(new AuthenticationProperties { RedirectUri = complete }, ExternalSignIn.OidcScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET /api/auth/external/complete — after the provider's callback.</summary>
|
||||||
|
[HttpGet(ExternalSignIn.CompletePath)]
|
||||||
|
public async Task<IActionResult> Complete([FromQuery] string? returnUrl)
|
||||||
|
{
|
||||||
|
returnUrl = LocalUrl(returnUrl);
|
||||||
|
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
|
||||||
|
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
|
||||||
|
return Redirect(WithQuery(returnUrl, "signin_error", "no_identity"));
|
||||||
|
var (issuer, subject, email) = identity;
|
||||||
|
|
||||||
|
var link = await signIn.FindAsync(issuer, subject);
|
||||||
|
var player = link is null ? null : await signIn.ActivePlayerAsync(link);
|
||||||
|
if (link is not null && player is not null)
|
||||||
|
{
|
||||||
|
await signIn.TouchAsync(link, email);
|
||||||
|
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player, link));
|
||||||
|
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
|
||||||
|
log.LogInformation("[signin] player={Player} via {Issuer}", player.Id, issuer);
|
||||||
|
return Redirect(returnUrl);
|
||||||
|
}
|
||||||
|
if (link is not null)
|
||||||
|
return Redirect(WithQuery(returnUrl, "signin_error", "account_unavailable"));
|
||||||
|
|
||||||
|
// Not linked yet. Linking happens on the website; the admin panel
|
||||||
|
// tells its user to link there first.
|
||||||
|
if (IsAdminHost())
|
||||||
|
return Redirect(WithQuery(returnUrl, "signin_error", "not_linked"));
|
||||||
|
return Redirect($"/link?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET /api/site/v1/account/me — who is signed in, or which
|
||||||
|
/// provider identity is waiting to be linked.</summary>
|
||||||
|
[HttpGet("api/site/v1/account/me")]
|
||||||
|
public async Task<IActionResult> Me()
|
||||||
|
{
|
||||||
|
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
|
||||||
|
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
|
||||||
|
var pending = external.Succeeded ? ExternalIdentity(external.Principal) : null;
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
signInAvailable = Options.IsConfigured,
|
||||||
|
provider = Options.DisplayName,
|
||||||
|
signedIn = web.Succeeded,
|
||||||
|
player = web.Succeeded
|
||||||
|
? new
|
||||||
|
{
|
||||||
|
id = ExternalSignIn.PlayerId(web.Principal)?.ToString(),
|
||||||
|
username = web.Principal!.Identity?.Name,
|
||||||
|
email = web.Principal.FindFirst(ExternalSignIn.EmailClaim)?.Value,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
pendingLink = pending is { } p ? new { email = p.Email } : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LinkRequest(string? Username, string? Password);
|
||||||
|
|
||||||
|
/// <summary>POST /api/site/v1/account/link {username, password} — link the
|
||||||
|
/// pending provider identity to the account these credentials prove.</summary>
|
||||||
|
[HttpPost("api/site/v1/account/link")]
|
||||||
|
public async Task<IActionResult> Link([FromBody] LinkRequest body)
|
||||||
|
{
|
||||||
|
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
|
||||||
|
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
|
||||||
|
return Unauthorized(new { error = "no_pending_identity" });
|
||||||
|
var (issuer, subject, email) = identity;
|
||||||
|
var username = body.Username?.Trim() ?? string.Empty;
|
||||||
|
if (username.Length == 0 || string.IsNullOrEmpty(body.Password))
|
||||||
|
return BadRequest(new { error = "missing_credentials" });
|
||||||
|
|
||||||
|
var keys = new[] { $"sub:{issuer}|{subject}", $"user:{username.ToLowerInvariant()}" };
|
||||||
|
if (limiter.IsBlocked(keys))
|
||||||
|
return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" });
|
||||||
|
|
||||||
|
var (outcome, player, link) = await signIn.LinkAsync(issuer, subject, email, username, body.Password);
|
||||||
|
switch (outcome)
|
||||||
|
{
|
||||||
|
case LinkOutcome.Linked:
|
||||||
|
limiter.Reset(keys);
|
||||||
|
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player!, link!));
|
||||||
|
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
|
||||||
|
log.LogInformation("[signin] linked player={Player} to {Issuer}", player!.Id, issuer);
|
||||||
|
return Ok(new { linked = true, username = player.Username });
|
||||||
|
case LinkOutcome.InvalidCredentials:
|
||||||
|
limiter.RecordFailure(keys);
|
||||||
|
return Unauthorized(new { error = "invalid_credentials" });
|
||||||
|
case LinkOutcome.Banned:
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, new { error = "banned" });
|
||||||
|
default:
|
||||||
|
return Conflict(new { error = "identity_linked_to_another_account" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/site/v1/account/unlink — remove this provider identity
|
||||||
|
/// from the account and sign out.</summary>
|
||||||
|
[HttpPost("api/site/v1/account/unlink")]
|
||||||
|
public async Task<IActionResult> Unlink()
|
||||||
|
{
|
||||||
|
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
|
||||||
|
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
|
||||||
|
return Unauthorized(new { error = "not_signed_in" });
|
||||||
|
var issuer = web.Principal!.FindFirst(ExternalSignIn.IssuerClaim)?.Value ?? string.Empty;
|
||||||
|
await signIn.UnlinkAsync(playerId, issuer);
|
||||||
|
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
|
||||||
|
return Ok(new { unlinked = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/site/v1/account/logout — end the session here.</summary>
|
||||||
|
[HttpPost("api/site/v1/account/logout")]
|
||||||
|
public async Task<IActionResult> Logout()
|
||||||
|
{
|
||||||
|
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
|
||||||
|
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
|
||||||
|
return Ok(new { signedOut = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(issuer, subject, email) from the provider's claims. The
|
||||||
|
/// subject is the account key; the email is display only.</summary>
|
||||||
|
private (string Issuer, string Subject, string Email)? ExternalIdentity(System.Security.Claims.ClaimsPrincipal? p)
|
||||||
|
{
|
||||||
|
var subject = p?.FindFirst("sub")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(subject)) return null;
|
||||||
|
var issuer = p!.FindFirst("iss")?.Value ?? Options.Authority;
|
||||||
|
return (issuer.TrimEnd('/'), subject, p.FindFirst("email")?.Value ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string LocalUrl(string? url) =>
|
||||||
|
!string.IsNullOrEmpty(url) && Url.IsLocalUrl(url) ? url : "/";
|
||||||
|
|
||||||
|
private static string WithQuery(string url, string key, string value) =>
|
||||||
|
url + (url.Contains('?') ? "&" : "?") + key + "=" + Uri.EscapeDataString(value);
|
||||||
|
|
||||||
|
private bool IsAdminHost() =>
|
||||||
|
string.Equals(Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -65,6 +65,12 @@ public class StorageController(
|
|||||||
|
|
||||||
[HttpPost("/upload", Order = -2000)]
|
[HttpPost("/upload", Order = -2000)]
|
||||||
[Consumes("multipart/form-data")]
|
[Consumes("multipart/form-data")]
|
||||||
|
// Kestrel's default 30,000,000-byte body cap would 413 the room save of
|
||||||
|
// a big circuits room now that the served CircuitsV2 chip limit is 30000
|
||||||
|
// (docs/recroom-2023-circuitsv2-limits.md). 100 MB matches the admin
|
||||||
|
// import endpoints and Cloudflare's own per-request ceiling.
|
||||||
|
[RequestSizeLimit(100_000_000)]
|
||||||
|
[RequestFormLimits(MultipartBodyLengthLimit = 100_000_000)]
|
||||||
public async Task<IActionResult> Upload()
|
public async Task<IActionResult> Upload()
|
||||||
{
|
{
|
||||||
// Form parsing — ASP.NET Core deserialises the multipart body
|
// Form parsing — ASP.NET Core deserialises the multipart body
|
||||||
|
|||||||
@@ -15,4 +15,21 @@ public class StringsController : ControllerBase
|
|||||||
[HttpGet("/strings/v1/{locale}")]
|
[HttpGet("/strings/v1/{locale}")]
|
||||||
public ActionResult<Dictionary<string, string>> GetLocale(string locale)
|
public ActionResult<Dictionary<string, string>> GetLocale(string locale)
|
||||||
=> Ok(new Dictionary<string, string>());
|
=> Ok(new Dictionary<string, string>());
|
||||||
|
|
||||||
|
/// <summary>The 2023 client's server-string download (RecNet.Runtime/NHILNDLLLHH.txt).
|
||||||
|
/// Two GETs share the path <c>/strings</c> on the strings host:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>?locale=&scopePrefix=client.&eTag=</c> (FMLBOAOBKPM, :531) →
|
||||||
|
/// <c>Dictionary<string, Dictionary<long,string>></c>, scope → id → text;</item>
|
||||||
|
/// <item><c>?locale=&scope=client.{name}&eTag=</c> (BNEELKAKCEN, :3) →
|
||||||
|
/// <c>Dictionary<long,string></c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// The localisation service stores the first answer
|
||||||
|
/// (RecRoom.Localization.Runtime/IBAJDNIMADI.txt BICKHBAJPIG) and its scope lookup
|
||||||
|
/// (NHLKEJLGGIE) null-derefs when the download never succeeded but returns an empty
|
||||||
|
/// table for a scope the server did not send. DorkNet keeps no server-side string
|
||||||
|
/// overrides, so the truthful answer is an empty JSON object for either query —
|
||||||
|
/// the lookups then fall back to the bundled text. The path used to 404.</summary>
|
||||||
|
[HttpGet("/strings")]
|
||||||
|
public IActionResult GetServerStrings() => Content("{}", "application/json");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using DorkNet.Server.Data.Entities;
|
using DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
namespace DorkNet.Server.Data;
|
namespace DorkNet.Server.Data;
|
||||||
|
|
||||||
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options)
|
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options), IDataProtectionKeyContext
|
||||||
{
|
{
|
||||||
public DbSet<PlayerEntity> Players => Set<PlayerEntity>();
|
public DbSet<PlayerEntity> Players => Set<PlayerEntity>();
|
||||||
public DbSet<AvatarEntity> Avatars => Set<AvatarEntity>();
|
public DbSet<AvatarEntity> Avatars => Set<AvatarEntity>();
|
||||||
@@ -11,6 +12,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
|||||||
public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>();
|
public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>();
|
||||||
public DbSet<RoomEntity> Rooms => Set<RoomEntity>();
|
public DbSet<RoomEntity> Rooms => Set<RoomEntity>();
|
||||||
public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>();
|
public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>();
|
||||||
|
public DbSet<RoomRolePermissionEntity> RoomRolePermissions => Set<RoomRolePermissionEntity>();
|
||||||
public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>();
|
public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>();
|
||||||
public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>();
|
public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>();
|
||||||
public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>();
|
public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>();
|
||||||
@@ -87,9 +89,19 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
|||||||
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
|
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
|
||||||
public DbSet<RoomConsumableEntity> RoomConsumables => Set<RoomConsumableEntity>();
|
public DbSet<RoomConsumableEntity> RoomConsumables => Set<RoomConsumableEntity>();
|
||||||
public DbSet<RoomConsumableOwnershipEntity> RoomConsumableOwnership => Set<RoomConsumableOwnershipEntity>();
|
public DbSet<RoomConsumableOwnershipEntity> RoomConsumableOwnership => Set<RoomConsumableOwnershipEntity>();
|
||||||
|
public DbSet<ExternalLoginEntity> ExternalLogins => Set<ExternalLoginEntity>();
|
||||||
|
// ASP.NET Core data-protection keys (sign-in cookies), kept in the
|
||||||
|
// database so a redeploy or a second replica doesn't sign everyone out.
|
||||||
|
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
modelBuilder.Entity<ExternalLoginEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(x => new { x.Issuer, x.Subject }).IsUnique();
|
||||||
|
e.HasIndex(x => x.PlayerId);
|
||||||
|
});
|
||||||
|
|
||||||
// SQLite-specific NOCASE collation works via UseCollation; Postgres
|
// SQLite-specific NOCASE collation works via UseCollation; Postgres
|
||||||
// doesn't have NOCASE. Provider-conditional helper applied to the
|
// doesn't have NOCASE. Provider-conditional helper applied to the
|
||||||
// three string columns the wire API matches case-insensitively
|
// three string columns the wire API matches case-insensitively
|
||||||
@@ -134,6 +146,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
|||||||
e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique();
|
e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// One cell per (room, sub-room, role, permission): the sub-room
|
||||||
|
// permissions PUT upserts the cells it was sent.
|
||||||
|
modelBuilder.Entity<RoomRolePermissionEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(p => p.Id);
|
||||||
|
e.HasIndex(p => new { p.RoomId, p.SubRoomId, p.Role, p.Permission }).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<ClubAdditionalImageEntity>(e =>
|
modelBuilder.Entity<ClubAdditionalImageEntity>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(i => i.Id);
|
e.HasKey(i => i.Id);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An identity from the external sign-in provider (OpenID Connect) linked to a
|
||||||
|
/// DorkNet account. One row per (Issuer, Subject): the provider's stable user
|
||||||
|
/// id, never the email, which can change or be reused. A player proves they own
|
||||||
|
/// the account once, with its password, when the link is made
|
||||||
|
/// (<c>ExternalSignInController.Link</c>); after that the provider alone signs
|
||||||
|
/// them in to the site and, for admins, the admin panel.
|
||||||
|
/// </summary>
|
||||||
|
public class ExternalLoginEntity
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public long PlayerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The provider's issuer URL (the <c>iss</c> claim).</summary>
|
||||||
|
public string Issuer { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The provider's user id (the <c>sub</c> claim).</summary>
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The email the provider reported when the link was made or last
|
||||||
|
/// used. Shown to the player; never used to find the account.</summary>
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime LastUsedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -116,6 +116,29 @@ public class PlayerEntity
|
|||||||
[MaxLength(32)]
|
[MaxLength(32)]
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
// ── 2023 profile fields ──────────────────────────────────────────────
|
||||||
|
// Written by PUT account/me/{emoji,bannerimage,personalpronouns,
|
||||||
|
// identityflags} and read back on every account DTO (account/me,
|
||||||
|
// account/{id}, account/bulk, search, AccountUpdate push) under the
|
||||||
|
// keys displayEmoji / bannerImage / personalPronouns / identityFlags.
|
||||||
|
|
||||||
|
/// <summary>Emoji shown beside the display name ("" = none).</summary>
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string DisplayEmoji { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Profile banner image name (a storage image the player
|
||||||
|
/// already uploaded, e.g. one of their photos). Null = no banner.</summary>
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string? BannerImageName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Pronoun bitmask (client flags enum: SheHer=1, HeHim=2,
|
||||||
|
/// TheyThem=4, ZeHir=8, ZeZir=16, XeXem=32).</summary>
|
||||||
|
public int PersonalPronouns { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>Identity-flag bitmask (client flags enum: LGBTQIA=1,
|
||||||
|
/// Transgender=2, Bisexual=4, …).</summary>
|
||||||
|
public int IdentityFlags { get; set; } = 0;
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public AvatarEntity? Avatar { get; set; }
|
public AvatarEntity? Avatar { get; set; }
|
||||||
public List<PlayerSettingEntity> Settings { get; set; } = [];
|
public List<PlayerSettingEntity> Settings { get; set; } = [];
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One cell of a sub-room's role-permission matrix, as written by
|
||||||
|
/// <c>PUT rooms/{roomId}/subrooms/{subRoomId}/permissions</c> and served back
|
||||||
|
/// in the <c>Permissions</c> list of <c>GET photon_access_token</c>.
|
||||||
|
///
|
||||||
|
/// The 2023-03-21 client models a cell as <c>BNLPBMJJOMM</c>
|
||||||
|
/// (formatter <c>RecNet.Runtime/HDOJMEBCAGP.txt</c>, keys
|
||||||
|
/// <c>Permission</c> / <c>Role</c> / <c>Override</c> / <c>Type</c> /
|
||||||
|
/// <c>Value</c>): Permission and Value are strings, Role is the room-role
|
||||||
|
/// enum <c>OMMBGJMJJPN</c> (None 0, Host 10, Moderator 20, CoOwner 30,
|
||||||
|
/// TemporaryCoOwner 31, Creator 255), Type is <c>FEOJFGECKGH</c>
|
||||||
|
/// (Bool 0, Int 1, StringList 2) and Override is a bool.
|
||||||
|
///
|
||||||
|
/// The editor sends only the cells it changed (the room-permissions runtime
|
||||||
|
/// builds a one-element <c>BNLPBMJJOMM[]</c> per edit,
|
||||||
|
/// <c>OMJKHJLFOCO.AFHJLBBFAGP.<LocalRequestModifyRolePermission>g__ChangeAndRefresh</c>),
|
||||||
|
/// so the write is an upsert per (room, sub-room, role, permission) and never
|
||||||
|
/// a replace of the whole matrix.
|
||||||
|
/// </summary>
|
||||||
|
public class RoomRolePermissionEntity
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public long RoomId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The wire sub-room id from the route (the same value the room
|
||||||
|
/// details advertise as <c>SubRooms[].SubRoomId</c>).</summary>
|
||||||
|
public long SubRoomId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wire room-role value (OMMBGJMJJPN), e.g. 30 = CoOwner, 255 = Creator.</summary>
|
||||||
|
public int Role { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The permission id exactly as the client sent it.</summary>
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string Permission { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>FEOJFGECKGH: 0 = Bool, 1 = Int, 2 = StringList.</summary>
|
||||||
|
public int Type { get; set; }
|
||||||
|
|
||||||
|
public bool Override { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The value exactly as the client sent it (a string on the wire).</summary>
|
||||||
|
[MaxLength(4096)]
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public long UpdatedByPlayerId { get; set; }
|
||||||
|
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -108,5 +108,14 @@ public class ServerSettingsEntity
|
|||||||
/// admins curate the base-room list from the SPA without a redeploy.</summary>
|
/// admins curate the base-room list from the SPA without a redeploy.</summary>
|
||||||
public string BaseRoomNamesJson { get; set; } = string.Empty;
|
public string BaseRoomNamesJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Admin-managed CircuitsV2 creation limits, served to the
|
||||||
|
/// client as the <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. The
|
||||||
|
/// network entry carries the per-frame RPC and byte budgets a circuit
|
||||||
|
/// may spend; DorkNet ships them effectively unlimited, where retail
|
||||||
|
/// shipped 5 RPCs/frame and 1024 bytes/60 frames. Stored as JSON so
|
||||||
|
/// new limit keys don't need a table migration.
|
||||||
|
/// See <see cref="Services.CircuitsV2LimitSettings"/>.</summary>
|
||||||
|
public string CircuitsV2LimitsJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`,
|
|||||||
|
|
||||||
| Verb | URL | Status | Handler |
|
| Verb | URL | Status | Handler |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `api/gameconfigs/v1/all` | STUB | `GameConfigurationsController.GameConfigsAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Returns `[]`. |
|
| GET | `api/gameconfigs/v1/all` | REAL | `GameConfigurationsController.GetAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Serves the admin-managed key/value set from `ServerSettingsService.GetGameConfigurationsAsync`: Rec Center doors, the typed keys confirmed in the 2020.12 decomp, the `UGC.CircuitsV2.Limit.*` family (see `docs/recroom-2023-circuitsv2-limits.md`), and `PlayMenuCarouselOrdering_1` (the 2023 play-menu carousel priority map; without it the client logs "Could not find GameConfig for PlayMenuCarouselOrdering_1" and keeps prefab order). |
|
||||||
|
|
||||||
### `api/groups/`
|
### `api/groups/`
|
||||||
|
|
||||||
@@ -391,6 +391,18 @@ These all live on `match.rec.net` (locally: `match.localhost`).
|
|||||||
| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it |
|
| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it |
|
||||||
| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case |
|
| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case |
|
||||||
|
|
||||||
|
2023 additions on the auth / accounts / strings hosts:
|
||||||
|
|
||||||
|
| Verb | URL | Status | Handler |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `connect/token` `grant_type=urn:ietf:params:oauth:grant-type:device_code` | REAL | `Auth.AuthController.Token` via `DeviceAuthorizations.RedeemAsync` — 400 `authorization_pending` until approved, then the approver's token pair; single use |
|
||||||
|
| POST | `connect/token` `grant_type=login_token` | REAL | `Auth.AuthController.Token` — redeems an `account/me/createlogintoken` token for its own `account_id`; single use |
|
||||||
|
| POST | `account/me/createlogintoken` | REAL | `Accounts.AccountsController.CreateLoginToken` — bare JSON string (the client reads `FGLDKEJLAKB<String>`) |
|
||||||
|
| POST | `account/me/remoteauth` | REAL | `Accounts.AccountsController.RemoteAuth` — approves a device `user_code` for the caller; `{success,error}` RecNetResult |
|
||||||
|
| POST | `account/bulk/email`, `account/bulk/phone` | REAL | `Accounts.AccountsController.Bulk` — contact lookup, repeated `email` / `phone` form fields, account list |
|
||||||
|
| GET | `strings?locale&scopePrefix|scope&eTag` | REAL | `Strings.StringsController.GetServerStrings` — empty table (no server-side string overrides) instead of a 404 |
|
||||||
|
| GET | `api/incentivizedreferrals/referrals?take&continuationToken` | REAL | `IncentivizedReferralsController.Referrals` — `{Referrals:[{InviteeAccountId,CreatedAt,VerifiedAt}],ContinuationToken}` |
|
||||||
|
|
||||||
### CDN host (`cdn.rec.net` / `storage` / `data`)
|
### CDN host (`cdn.rec.net` / `storage` / `data`)
|
||||||
|
|
||||||
Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host).
|
Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host).
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user