mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4985bb09d4 | |||
| 32cd60792f | |||
| 955246c6e4 | |||
| 34506e8294 | |||
| 11377b46d5 | |||
| a840355489 | |||
| b26ae93014 | |||
| f6a4fdc5fb | |||
| 1eba8d9345 | |||
| a9518b08ab | |||
| 82c6f72219 | |||
| 80cc4ce0bd | |||
| cd9d10586c | |||
| 46e2bb15e4 | |||
| 676dcd09a7 | |||
| ecbf192e47 | |||
| bc8bcd99d0 | |||
| 1f407ef7b3 | |||
| d471b418b9 |
@@ -1,78 +0,0 @@
|
||||
# Disabled template. GitHub ignores files outside .github/workflows/.
|
||||
# Move this file to .github/workflows/endpoint-contract.yml when it should run.
|
||||
name: Endpoint Contract
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
endpoint-contract:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ASPNETCORE_URLS: http://127.0.0.1:18080
|
||||
Database__Provider: sqlite
|
||||
Database__SqlitePath: ${{ runner.temp }}/dorknet-endpoint-contract.db
|
||||
DORKNET_JWT_SECRET: endpoint-contract-secret-000000000000000000000000000000000000000000
|
||||
S3__Endpoint: ""
|
||||
S3__AccessKey: ""
|
||||
S3__SecretKey: ""
|
||||
PROTOBUF_PROTOC: /usr/bin/protoc
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Install protobuf compiler
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y protobuf-compiler
|
||||
|
||||
- name: Build server
|
||||
run: dotnet build DorkNet.Server/DorkNet.Server.csproj
|
||||
|
||||
- name: Build endpoint contract runner
|
||||
run: dotnet build tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-restore
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
|
||||
|
||||
- name: Wait for server readiness
|
||||
run: |
|
||||
for attempt in {1..60}; do
|
||||
if curl --fail --silent http://127.0.0.1:18080/healthz; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
cat "$RUNNER_TEMP/dorknet-server.log"
|
||||
exit 1
|
||||
|
||||
- name: Run endpoint contract
|
||||
run: |
|
||||
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
|
||||
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
|
||||
--base-url http://127.0.0.1:18080 \
|
||||
--report "$RUNNER_TEMP/endpoint-contract-report.json"
|
||||
|
||||
- name: Upload endpoint contract artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: endpoint-contract
|
||||
path: |
|
||||
${{ runner.temp }}/endpoint-contract-report.json
|
||||
${{ runner.temp }}/dorknet-server.log
|
||||
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "$RUNNER_TEMP/dorknet-server.pid" ]; then
|
||||
kill "$(cat "$RUNNER_TEMP/dorknet-server.pid")" || true
|
||||
fi
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release (december-2020-12-18)
|
||||
name: Release (march-2020-03-10)
|
||||
|
||||
# Auto-publishes dorknet-server + dorknet-clientpatch zips to a
|
||||
# GitHub Release on tag push. The launcher (on the public main
|
||||
@@ -9,17 +9,14 @@ name: Release (december-2020-12-18)
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v1-december-*'
|
||||
|
||||
permissions:
|
||||
contents: write # create the GitHub release + upload assets
|
||||
- 'v1-march-*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
|
||||
env:
|
||||
VERSION_KEY: december_2020_12_18
|
||||
VERSION_KEY: march_2020_03_10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
+2
-26
@@ -8,6 +8,7 @@ build/
|
||||
.vscode/
|
||||
*.csproj.bak
|
||||
dist/
|
||||
local-releases/
|
||||
## Data / secrets
|
||||
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
||||
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
||||
@@ -48,7 +49,6 @@ dist$ZIPNAME.zip
|
||||
## time someone rebuilds. Keep the source committed, regenerate output.
|
||||
DorkNet.Server/wwwroot/admin/index.html
|
||||
DorkNet.Server/wwwroot/admin/assets/
|
||||
DorkNet.Server/wwwroot/admin/brand/
|
||||
DorkNet.Server/wwwroot/admin/*.map
|
||||
DorkNet.Server/admin-ui/node_modules/
|
||||
DorkNet.Server/admin-ui/dist/
|
||||
@@ -59,33 +59,9 @@ DorkNet.Server/admin-ui/tsconfig.tsbuildinfo
|
||||
## Same reasoning as admin-ui above: hashed output, regenerate per build.
|
||||
DorkNet.Server/wwwroot/site/index.html
|
||||
DorkNet.Server/wwwroot/site/assets/
|
||||
DorkNet.Server/wwwroot/site/brand/
|
||||
DorkNet.Server/wwwroot/site/*.map
|
||||
DorkNet.Server/site/node_modules/
|
||||
DorkNet.Server/site/dist/
|
||||
DorkNet.Server/site/.vite/
|
||||
DorkNet.Server/site/tsconfig.tsbuildinfo
|
||||
|
||||
|
||||
steamless/
|
||||
|
||||
## Local recovery / cache-mining artifacts produced by tools/recover-inventions.ps1
|
||||
## and friends. These can run hundreds of MB to multi-GB (HTTPCache snapshots,
|
||||
## raw .inv bytes staged for re-import, ready-to-import zip bundles). Per-room,
|
||||
## per-run, regeneratable from the source room zip + a live walkthrough — never
|
||||
## belongs in source control.
|
||||
*-cache-snapshot/
|
||||
*-cache-snapshot-*/
|
||||
*-recovered-inv/
|
||||
*-recovered.zip
|
||||
DorkSquad-recovered/
|
||||
ProficioVII-recovered/
|
||||
Recovered.zip
|
||||
Recovered/
|
||||
Recovered(*)/
|
||||
|
||||
|
||||
local-releases/**
|
||||
DorkNet.Models/bin-verify/**
|
||||
DorkNet.Models/bin-verify2/**
|
||||
identityserver-signing.pfx
|
||||
local-releases.zip
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||
WORKDIR /ui
|
||||
COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./
|
||||
RUN npm install --no-fund --no-audit --loglevel=error
|
||||
COPY DorkNet.Server/admin-ui/ ./
|
||||
RUN mkdir -p /wwwroot/admin \
|
||||
&& npm run build
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS site
|
||||
WORKDIR /ui
|
||||
COPY DorkNet.Server/site/package.json DorkNet.Server/site/package-lock.json* ./
|
||||
RUN npm install --no-fund --no-audit --loglevel=error
|
||||
COPY DorkNet.Server/site/ ./
|
||||
RUN mkdir -p /wwwroot/site \
|
||||
&& npm run build
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
|
||||
ARG PROJECT_PATH
|
||||
WORKDIR /src
|
||||
|
||||
COPY . .
|
||||
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
|
||||
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
|
||||
RUN dotnet publish "${PROJECT_PATH}" -c Release \
|
||||
-p:SkipAdminUIBuild=true \
|
||||
-p:DorkNetServiceContainerPublish=true \
|
||||
-o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
|
||||
ARG APP_DLL
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER app
|
||||
|
||||
COPY --from=build --chown=app:app /app/publish .
|
||||
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||
ASPNETCORE_URLS=http://0.0.0.0:8080 \
|
||||
DOTNET_RUNNING_IN_CONTAINER=true \
|
||||
DOTNET_PRINT_TELEMETRY_MESSAGE=false \
|
||||
APP_DLL=${APP_DLL}
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD curl --fail --silent http://localhost:8080/healthz || exit 1
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "dotnet \"$APP_DLL\""]
|
||||
@@ -1,39 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui.Controls;
|
||||
using DorkNet.AdminMobile.Design;
|
||||
using DorkNet.AdminMobile.Pages;
|
||||
|
||||
namespace DorkNet.AdminMobile;
|
||||
|
||||
public class App : Application
|
||||
{
|
||||
public App(LoginPage loginPage)
|
||||
{
|
||||
UserAppTheme = AppTheme.Dark;
|
||||
MainPage = new NavigationPage(loginPage)
|
||||
{
|
||||
BarBackgroundColor = AppDesign.Canvas,
|
||||
BarTextColor = AppDesign.Text,
|
||||
};
|
||||
}
|
||||
|
||||
public static void ShowAdmin()
|
||||
{
|
||||
Current!.MainPage = new NavigationPage(
|
||||
AppServices.Services.GetRequiredService<AdminTabbedPage>())
|
||||
{
|
||||
BarBackgroundColor = AppDesign.Canvas,
|
||||
BarTextColor = AppDesign.Text,
|
||||
};
|
||||
}
|
||||
|
||||
public static void ShowLogin()
|
||||
{
|
||||
Current!.MainPage = new NavigationPage(
|
||||
AppServices.Services.GetRequiredService<LoginPage>())
|
||||
{
|
||||
BarBackgroundColor = AppDesign.Canvas,
|
||||
BarTextColor = AppDesign.Text,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Shapes;
|
||||
|
||||
namespace DorkNet.AdminMobile.Design;
|
||||
|
||||
public static class AppDesign
|
||||
{
|
||||
public static readonly Color Canvas = Color.FromArgb("#0f1115");
|
||||
public static readonly Color Surface = Color.FromArgb("#171a20");
|
||||
public static readonly Color SurfaceLifted = Color.FromArgb("#20252d");
|
||||
public static readonly Color RowSurface = Color.FromArgb("#14171d");
|
||||
public static readonly Color Stroke = Color.FromArgb("#2a3039");
|
||||
public static readonly Color Text = Color.FromArgb("#f4f6fa");
|
||||
public static readonly Color Muted = Color.FromArgb("#aeb7c6");
|
||||
public static readonly Color Subtle = Color.FromArgb("#778294");
|
||||
public static readonly Color Accent = Color.FromArgb("#64b5ff");
|
||||
public static readonly Color AndroidAccent = Color.FromArgb("#b7dd72");
|
||||
public static readonly Color Danger = Color.FromArgb("#ff6f82");
|
||||
|
||||
public static Brush PageBackground => new SolidColorBrush(Canvas);
|
||||
|
||||
public static Color PlatformAccent =>
|
||||
#if ANDROID
|
||||
AndroidAccent;
|
||||
#else
|
||||
Accent;
|
||||
#endif
|
||||
|
||||
public static Label Title(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
TextColor = Text,
|
||||
FontSize = 22,
|
||||
FontAttributes = FontAttributes.Bold,
|
||||
LineBreakMode = LineBreakMode.TailTruncation,
|
||||
};
|
||||
|
||||
public static Label Section(string text) => new()
|
||||
{
|
||||
Text = text.ToUpperInvariant(),
|
||||
TextColor = Subtle,
|
||||
FontSize = 12,
|
||||
FontAttributes = FontAttributes.Bold,
|
||||
Margin = new Thickness(0, 12, 0, 0),
|
||||
};
|
||||
|
||||
public static Border GlassCard(View content, Thickness? padding = null) => new()
|
||||
{
|
||||
Padding = padding ?? new Thickness(14),
|
||||
Stroke = Stroke,
|
||||
StrokeThickness = 1,
|
||||
BackgroundColor = Surface,
|
||||
StrokeShape = new RoundRectangle { CornerRadius = 10 },
|
||||
Content = content,
|
||||
};
|
||||
|
||||
public static Button PrimaryButton(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
BackgroundColor = PlatformAccent,
|
||||
TextColor = Colors.Black,
|
||||
CornerRadius = 10,
|
||||
HeightRequest = 44,
|
||||
FontAttributes = FontAttributes.Bold,
|
||||
Padding = new Thickness(14, 0),
|
||||
};
|
||||
|
||||
public static Button SecondaryButton(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
BackgroundColor = SurfaceLifted,
|
||||
TextColor = Text,
|
||||
CornerRadius = 10,
|
||||
HeightRequest = 42,
|
||||
Padding = new Thickness(14, 0),
|
||||
};
|
||||
|
||||
public static Button DangerButton(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
BackgroundColor = Color.FromArgb("#4c1d28"),
|
||||
TextColor = Color.FromArgb("#ffd5dc"),
|
||||
CornerRadius = 10,
|
||||
HeightRequest = 42,
|
||||
Padding = new Thickness(12, 0),
|
||||
};
|
||||
|
||||
public static Entry Entry(string placeholder, Keyboard? keyboard = null, bool secret = false) => new()
|
||||
{
|
||||
Placeholder = placeholder,
|
||||
IsPassword = secret,
|
||||
TextColor = Text,
|
||||
PlaceholderColor = Subtle,
|
||||
BackgroundColor = Color.FromArgb("#19202b"),
|
||||
ClearButtonVisibility = ClearButtonVisibility.WhileEditing,
|
||||
Keyboard = keyboard ?? Keyboard.Text,
|
||||
HeightRequest = 44,
|
||||
};
|
||||
|
||||
public static Label Caption(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
TextColor = Muted,
|
||||
FontSize = 13,
|
||||
LineBreakMode = LineBreakMode.WordWrap,
|
||||
};
|
||||
|
||||
public static BoxView Divider() => new()
|
||||
{
|
||||
HeightRequest = 1,
|
||||
Color = Stroke,
|
||||
Opacity = 0.7,
|
||||
};
|
||||
|
||||
public static Label StatusLabel() => new()
|
||||
{
|
||||
TextColor = Muted,
|
||||
FontSize = 13,
|
||||
};
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net9.0-android36.0;net9.0-ios26.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>DorkNet.AdminMobile</RootNamespace>
|
||||
<UseMaui>true</UseMaui>
|
||||
<SingleProject>true</SingleProject>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationTitle>DorkNet Admin</ApplicationTitle>
|
||||
<ApplicationId>net.dorknet.adminmobile</ApplicationId>
|
||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>3</ApplicationVersion>
|
||||
<AndroidTargetSdkVersion>36</AndroidTargetSdkVersion>
|
||||
<RuntimeIdentifiers>android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
|
||||
<PublishTrimmed Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</PublishTrimmed>
|
||||
<RunAOTCompilation Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</RunAOTCompilation>
|
||||
<AndroidLinkMode Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">None</AndroidLinkMode>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">23.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#0B0F14" />
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#0B0F14" BaseSize="256,256" />
|
||||
<MauiImage Include="Resources\Brand\dorknet.svg" BaseSize="256,256" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.120" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,34 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui.Controls.Hosting;
|
||||
using Microsoft.Maui.Hosting;
|
||||
using DorkNet.AdminMobile.Pages;
|
||||
using DorkNet.AdminMobile.Services;
|
||||
|
||||
namespace DorkNet.AdminMobile;
|
||||
|
||||
public static class MauiProgram
|
||||
{
|
||||
public static MauiApp CreateMauiApp()
|
||||
{
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder.UseMauiApp<App>();
|
||||
|
||||
builder.Services.AddSingleton<SecureAdminSettings>();
|
||||
builder.Services.AddSingleton<AdminApiClient>();
|
||||
builder.Services.AddTransient<LoginPage>();
|
||||
builder.Services.AddTransient<AdminTabbedPage>();
|
||||
builder.Services.AddTransient<DashboardPage>();
|
||||
builder.Services.AddTransient<PlayersPage>();
|
||||
builder.Services.AddTransient<RoomsPage>();
|
||||
builder.Services.AddTransient<SettingsPage>();
|
||||
|
||||
var app = builder.Build();
|
||||
AppServices.Services = app.Services;
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
public static class AppServices
|
||||
{
|
||||
public static IServiceProvider Services { get; set; } = default!;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.AdminMobile.Models;
|
||||
|
||||
public sealed class AdminLoginResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("account_id")]
|
||||
public long AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("display_name")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class PlayerSummary
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? Email { get; set; }
|
||||
public bool IsAdmin { get; set; }
|
||||
public bool IsDeveloper { get; set; }
|
||||
public bool IsVerified { get; set; }
|
||||
public bool IsJunior { get; set; }
|
||||
public bool Online { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int XP { get; set; }
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
public DateTime? BannedUntil { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RoomSummary
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public long CreatorPlayerId { get; set; }
|
||||
public int BlobCount { get; set; }
|
||||
public bool IsAGRoom { get; set; }
|
||||
public bool IsDormRoom { get; set; }
|
||||
}
|
||||
|
||||
public sealed class InstanceSummary
|
||||
{
|
||||
public long RoomInstanceId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public string RoomName { get; set; } = string.Empty;
|
||||
public string PhotonRoomId { get; set; } = string.Empty;
|
||||
public string PhotonRegionId { get; set; } = string.Empty;
|
||||
public int MaxCapacity { get; set; }
|
||||
public bool IsPrivate { get; set; }
|
||||
public List<InstanceParticipant> Participants { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class InstanceParticipant
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public bool IsMaster { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ServerSettingsDto
|
||||
{
|
||||
public bool SignupsDisabled { get; set; }
|
||||
public bool GlobalFriendsEnabled { get; set; }
|
||||
public bool WeeklyChallengesCompletedRequired { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AdminStats
|
||||
{
|
||||
public PlayerStats Players { get; set; } = new();
|
||||
public RoomStats Rooms { get; set; } = new();
|
||||
public int Inventions { get; set; }
|
||||
public PhotoStats Photos { get; set; } = new();
|
||||
public ModerationStats Moderation { get; set; } = new();
|
||||
public DateTime ServerTime { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlayerStats
|
||||
{
|
||||
public int Total { get; set; }
|
||||
public int OnlineNow { get; set; }
|
||||
public int BannedNow { get; set; }
|
||||
public int NewToday { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RoomStats
|
||||
{
|
||||
public int Total { get; set; }
|
||||
public long TotalVisits { get; set; }
|
||||
public long TotalCheers { get; set; }
|
||||
public int ActiveSessionCount { get; set; }
|
||||
public int InGamePlayerCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PhotoStats
|
||||
{
|
||||
public int Today { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ModerationStats
|
||||
{
|
||||
public int OpenReports { get; set; }
|
||||
public int ActiveIpBans { get; set; }
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Microsoft.Maui.Controls;
|
||||
using DorkNet.AdminMobile.Design;
|
||||
|
||||
namespace DorkNet.AdminMobile.Pages;
|
||||
|
||||
public sealed class AdminTabbedPage : TabbedPage
|
||||
{
|
||||
public AdminTabbedPage(
|
||||
DashboardPage dashboard,
|
||||
PlayersPage players,
|
||||
RoomsPage rooms,
|
||||
SettingsPage settings)
|
||||
{
|
||||
BarBackgroundColor = AppDesign.Canvas;
|
||||
BarTextColor = AppDesign.Text;
|
||||
SelectedTabColor = AppDesign.PlatformAccent;
|
||||
UnselectedTabColor = AppDesign.Subtle;
|
||||
Children.Add(dashboard);
|
||||
Children.Add(players);
|
||||
Children.Add(rooms);
|
||||
Children.Add(settings);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
using Microsoft.Maui.Controls;
|
||||
using DorkNet.AdminMobile.Design;
|
||||
using DorkNet.AdminMobile.Models;
|
||||
using DorkNet.AdminMobile.Services;
|
||||
|
||||
namespace DorkNet.AdminMobile.Pages;
|
||||
|
||||
public sealed class DashboardPage : ContentPage
|
||||
{
|
||||
private readonly AdminApiClient api;
|
||||
private readonly Label status = AppDesign.StatusLabel();
|
||||
private readonly Grid metrics = new() { ColumnSpacing = 10, RowSpacing = 10 };
|
||||
|
||||
public DashboardPage(AdminApiClient api)
|
||||
{
|
||||
this.api = api;
|
||||
Title = "Dashboard";
|
||||
Background = AppDesign.PageBackground;
|
||||
|
||||
var refresh = AppDesign.SecondaryButton("Refresh");
|
||||
refresh.Clicked += async (_, _) => await LoadAsync();
|
||||
|
||||
Content = new ScrollView
|
||||
{
|
||||
Content = new VerticalStackLayout
|
||||
{
|
||||
Padding = 16,
|
||||
Spacing = 14,
|
||||
Children =
|
||||
{
|
||||
AppDesign.Title("Operations"),
|
||||
refresh,
|
||||
metrics,
|
||||
status,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (metrics.Children.Count == 0) await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
status.Text = "Loading...";
|
||||
var s = await api.GetStatsAsync();
|
||||
DrawMetrics(s);
|
||||
status.Text = $"Server time {s.ServerTime:g}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status.Text = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawMetrics(AdminStats s)
|
||||
{
|
||||
metrics.Children.Clear();
|
||||
metrics.RowDefinitions.Clear();
|
||||
metrics.ColumnDefinitions.Clear();
|
||||
metrics.ColumnDefinitions.Add(new ColumnDefinition());
|
||||
metrics.ColumnDefinitions.Add(new ColumnDefinition());
|
||||
AddMetric(0, 0, "Players", $"{s.Players.Total}", $"{s.Players.OnlineNow} online");
|
||||
AddMetric(0, 1, "Rooms", $"{s.Rooms.Total}", $"{s.Rooms.ActiveSessionCount} active sessions");
|
||||
AddMetric(1, 0, "Moderation", $"{s.Moderation.OpenReports}", "open reports");
|
||||
AddMetric(1, 1, "Traffic", $"{s.Rooms.InGamePlayerCount}", $"{s.Rooms.TotalVisits:n0} visits");
|
||||
AddMetric(2, 0, "New today", $"{s.Players.NewToday}", "players");
|
||||
AddMetric(2, 1, "Photos", $"{s.Photos.Today}", "last 24h");
|
||||
}
|
||||
|
||||
private void AddMetric(int row, int col, string label, string value, string caption)
|
||||
{
|
||||
while (metrics.RowDefinitions.Count <= row) metrics.RowDefinitions.Add(new RowDefinition());
|
||||
var tile = AppDesign.GlassCard(new VerticalStackLayout
|
||||
{
|
||||
Spacing = 4,
|
||||
Children =
|
||||
{
|
||||
new Label { Text = label, TextColor = AppDesign.Subtle, FontSize = 12 },
|
||||
new Label { Text = value, TextColor = AppDesign.Text, FontSize = 28, FontAttributes = FontAttributes.Bold },
|
||||
new Label { Text = caption, TextColor = AppDesign.Muted, FontSize = 12 },
|
||||
},
|
||||
}, new Thickness(14));
|
||||
metrics.Add(tile, col, row);
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
using Microsoft.Maui.Controls;
|
||||
using DorkNet.AdminMobile.Design;
|
||||
using DorkNet.AdminMobile.Services;
|
||||
|
||||
namespace DorkNet.AdminMobile.Pages;
|
||||
|
||||
public sealed class LoginPage : ContentPage
|
||||
{
|
||||
private readonly AdminApiClient api;
|
||||
private readonly SecureAdminSettings settings;
|
||||
private readonly Entry baseUrl = AppDesign.Entry("https://admin.example.com", Keyboard.Url);
|
||||
private readonly Entry cfClientId = AppDesign.Entry("Cloudflare Access Client ID");
|
||||
private readonly Entry cfClientSecret = AppDesign.Entry("Cloudflare Access Client Secret", secret: true);
|
||||
private readonly Entry cfJwt = AppDesign.Entry("Cloudflare Access JWT assertion", secret: true);
|
||||
private readonly Entry username = AppDesign.Entry("Admin username");
|
||||
private readonly Entry password = AppDesign.Entry("Password", secret: true);
|
||||
private readonly Label status = AppDesign.StatusLabel();
|
||||
private readonly ActivityIndicator busy = new() { IsVisible = false };
|
||||
private bool loaded;
|
||||
|
||||
public LoginPage(AdminApiClient api, SecureAdminSettings settings)
|
||||
{
|
||||
this.api = api;
|
||||
this.settings = settings;
|
||||
Title = "DorkNet Admin";
|
||||
Background = AppDesign.PageBackground;
|
||||
|
||||
var login = AppDesign.PrimaryButton("Sign in");
|
||||
login.Clicked += OnLogin;
|
||||
|
||||
var continueSaved = AppDesign.SecondaryButton("Continue saved session");
|
||||
continueSaved.Clicked += async (_, _) =>
|
||||
{
|
||||
if (await settings.GetJwtAsync() is { Length: > 0 }) App.ShowAdmin();
|
||||
else status.Text = "No saved DorkNet admin session is stored.";
|
||||
};
|
||||
|
||||
Content = new ScrollView
|
||||
{
|
||||
Content = new VerticalStackLayout
|
||||
{
|
||||
Padding = new Thickness(20, 28),
|
||||
Spacing = 14,
|
||||
Children =
|
||||
{
|
||||
new Image
|
||||
{
|
||||
Source = "dorknet.svg",
|
||||
WidthRequest = 96,
|
||||
HeightRequest = 96,
|
||||
HorizontalOptions = LayoutOptions.Start,
|
||||
},
|
||||
AppDesign.Title("Admin mobile"),
|
||||
new Label
|
||||
{
|
||||
Text = "Connect to a DorkNet admin host. Cloudflare Access service tokens are sent with every request when configured.",
|
||||
TextColor = AppDesign.Muted,
|
||||
FontSize = 14,
|
||||
},
|
||||
AppDesign.GlassCard(new VerticalStackLayout
|
||||
{
|
||||
Spacing = 10,
|
||||
Children =
|
||||
{
|
||||
AppDesign.Section("Connection"),
|
||||
baseUrl,
|
||||
cfClientId,
|
||||
cfClientSecret,
|
||||
cfJwt,
|
||||
},
|
||||
}),
|
||||
AppDesign.GlassCard(new VerticalStackLayout
|
||||
{
|
||||
Spacing = 10,
|
||||
Children =
|
||||
{
|
||||
AppDesign.Section("DorkNet admin login"),
|
||||
username,
|
||||
password,
|
||||
login,
|
||||
continueSaved,
|
||||
},
|
||||
}),
|
||||
busy,
|
||||
status,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
try
|
||||
{
|
||||
var saved = await settings.LoadConnectionAsync();
|
||||
baseUrl.Text = saved.BaseUrl;
|
||||
cfClientId.Text = saved.CloudflareAccessClientId;
|
||||
cfClientSecret.Text = saved.CloudflareAccessClientSecret;
|
||||
cfJwt.Text = saved.CloudflareAccessJwt;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
baseUrl.Text = "https://admin.rec.net";
|
||||
status.Text = $"Secure storage was reset: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnLogin(object? sender, EventArgs e)
|
||||
{
|
||||
busy.IsVisible = true;
|
||||
busy.IsRunning = true;
|
||||
status.Text = string.Empty;
|
||||
try
|
||||
{
|
||||
await settings.SaveConnectionAsync(new AdminConnectionSettings(
|
||||
baseUrl.Text ?? string.Empty,
|
||||
cfClientId.Text ?? string.Empty,
|
||||
cfClientSecret.Text ?? string.Empty,
|
||||
cfJwt.Text ?? string.Empty));
|
||||
await api.LoginAsync(username.Text ?? string.Empty, password.Text ?? string.Empty);
|
||||
App.ShowAdmin();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status.Text = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
busy.IsRunning = false;
|
||||
busy.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
using Microsoft.Maui.Controls;
|
||||
using DorkNet.AdminMobile.Design;
|
||||
using DorkNet.AdminMobile.Models;
|
||||
using DorkNet.AdminMobile.Services;
|
||||
|
||||
namespace DorkNet.AdminMobile.Pages;
|
||||
|
||||
public sealed class PlayersPage : ContentPage
|
||||
{
|
||||
private readonly AdminApiClient api;
|
||||
private readonly Entry search = AppDesign.Entry("Search players");
|
||||
private readonly CollectionView list = new();
|
||||
private readonly Label status = AppDesign.StatusLabel();
|
||||
private readonly Button refresh = AppDesign.SecondaryButton("Refresh");
|
||||
private List<PlayerSummary> players = [];
|
||||
|
||||
public PlayersPage(AdminApiClient api)
|
||||
{
|
||||
this.api = api;
|
||||
Title = "Players";
|
||||
Background = AppDesign.PageBackground;
|
||||
|
||||
refresh.Clicked += async (_, _) => await LoadAsync();
|
||||
search.Completed += async (_, _) => await LoadAsync();
|
||||
|
||||
list.SelectionMode = SelectionMode.Single;
|
||||
list.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
|
||||
list.SelectionChanged += async (_, e) =>
|
||||
{
|
||||
if (e.CurrentSelection.FirstOrDefault() is PlayerSummary player)
|
||||
{
|
||||
list.SelectedItem = null;
|
||||
await PlayerActionsAsync(player);
|
||||
}
|
||||
};
|
||||
list.EmptyView = new Label
|
||||
{
|
||||
Text = "No players loaded",
|
||||
TextColor = AppDesign.Subtle,
|
||||
HorizontalTextAlignment = TextAlignment.Center,
|
||||
VerticalTextAlignment = TextAlignment.Center,
|
||||
};
|
||||
list.ItemTemplate = new DataTemplate(() =>
|
||||
{
|
||||
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 16 };
|
||||
name.SetBinding(Label.TextProperty, nameof(PlayerSummary.DisplayName));
|
||||
var handle = new Label { TextColor = AppDesign.Muted, FontSize = 13 };
|
||||
handle.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Username), stringFormat: "@{0}"));
|
||||
var detail = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
|
||||
detail.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Level), stringFormat: "Level {0}"));
|
||||
var online = new Label { TextColor = AppDesign.AndroidAccent, FontSize = 12, FontAttributes = FontAttributes.Bold };
|
||||
online.SetBinding(Label.IsVisibleProperty, nameof(PlayerSummary.Online));
|
||||
online.Text = "ONLINE";
|
||||
var chevron = new Label
|
||||
{
|
||||
Text = ">",
|
||||
TextColor = AppDesign.Subtle,
|
||||
FontSize = 18,
|
||||
VerticalTextAlignment = TextAlignment.Center,
|
||||
};
|
||||
var row = new Grid
|
||||
{
|
||||
RowDefinitions =
|
||||
{
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
},
|
||||
ColumnDefinitions =
|
||||
{
|
||||
new ColumnDefinition(),
|
||||
new ColumnDefinition { Width = GridLength.Auto },
|
||||
new ColumnDefinition { Width = GridLength.Auto },
|
||||
},
|
||||
Padding = new Thickness(12, 10),
|
||||
RowSpacing = 2,
|
||||
ColumnSpacing = 10,
|
||||
BackgroundColor = AppDesign.RowSurface,
|
||||
};
|
||||
row.Add(name, 0, 0);
|
||||
row.Add(online, 1, 0);
|
||||
row.Add(handle, 0, 1);
|
||||
row.Add(detail, 0, 2);
|
||||
row.Add(chevron, 2, 0);
|
||||
Grid.SetRowSpan(chevron, 3);
|
||||
|
||||
return new VerticalStackLayout
|
||||
{
|
||||
Spacing = 0,
|
||||
Children = { row, AppDesign.Divider() },
|
||||
};
|
||||
});
|
||||
|
||||
var searchRow = new Grid
|
||||
{
|
||||
ColumnSpacing = 8,
|
||||
ColumnDefinitions =
|
||||
{
|
||||
new ColumnDefinition(),
|
||||
new ColumnDefinition { Width = GridLength.Auto },
|
||||
},
|
||||
};
|
||||
searchRow.Add(search, 0, 0);
|
||||
searchRow.Add(refresh, 1, 0);
|
||||
|
||||
var grid = new Grid
|
||||
{
|
||||
Padding = 16,
|
||||
RowSpacing = 12,
|
||||
RowDefinitions =
|
||||
{
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
new RowDefinition(),
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
},
|
||||
};
|
||||
grid.Add(AppDesign.Title("Players"), 0, 0);
|
||||
grid.Add(searchRow, 0, 1);
|
||||
grid.Add(list, 0, 2);
|
||||
grid.Add(status, 0, 3);
|
||||
Content = grid;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (players.Count == 0) await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
refresh.IsEnabled = false;
|
||||
status.Text = "Loading...";
|
||||
players = await api.GetPlayersAsync(search.Text);
|
||||
list.ItemsSource = players;
|
||||
status.Text = $"{players.Count} players loaded";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status.Text = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
refresh.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PlayerActionsAsync(PlayerSummary player)
|
||||
{
|
||||
var action = await DisplayActionSheet(
|
||||
$"@{player.Username}",
|
||||
"Cancel",
|
||||
"Remove account",
|
||||
"Kick",
|
||||
"Reset avatar");
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "Kick":
|
||||
await ConfirmAction($"Kick @{player.Username}?", () => api.KickPlayerAsync(player.Id, "Kicked from mobile admin."));
|
||||
break;
|
||||
case "Reset avatar":
|
||||
await ConfirmAction($"Reset @{player.Username}'s avatar?", () => api.ResetAvatarAsync(player.Id));
|
||||
break;
|
||||
case "Remove account":
|
||||
await RemoveAccountAsync(player);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConfirmAction(string message, Func<Task> action)
|
||||
{
|
||||
if (!await DisplayAlert("Confirm", message, "Run", "Cancel")) return;
|
||||
try
|
||||
{
|
||||
await action();
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await DisplayAlert("Request failed", ex.Message, "OK");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveAccountAsync(PlayerSummary player)
|
||||
{
|
||||
var username = await DisplayPromptAsync(
|
||||
"Verify username",
|
||||
$"Type {player.Username} exactly to remove @{player.Username}.",
|
||||
"Next",
|
||||
"Cancel",
|
||||
maxLength: Math.Max(32, player.Username.Length),
|
||||
keyboard: Keyboard.Text);
|
||||
if (!string.Equals(username?.Trim(), player.Username, StringComparison.Ordinal)) return;
|
||||
|
||||
var phrase = $"DELETE {player.Id}";
|
||||
var typedPhrase = await DisplayPromptAsync(
|
||||
"Verify removal",
|
||||
$"Type {phrase} exactly. This cannot be undone.",
|
||||
"Remove",
|
||||
"Cancel",
|
||||
maxLength: phrase.Length,
|
||||
keyboard: Keyboard.Text);
|
||||
if (!string.Equals(typedPhrase?.Trim(), phrase, StringComparison.Ordinal)) return;
|
||||
|
||||
if (!await DisplayAlert("Remove account", $"Permanently remove @{player.Username}?", "Remove", "Cancel")) return;
|
||||
|
||||
try
|
||||
{
|
||||
status.Text = $"Removing @{player.Username}...";
|
||||
await api.DeletePlayerAsync(player.Id, player.Username, phrase, "Removed from mobile admin.");
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await DisplayAlert("Removal failed", ex.Message, "OK");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
using Microsoft.Maui.Controls;
|
||||
using DorkNet.AdminMobile.Design;
|
||||
using DorkNet.AdminMobile.Models;
|
||||
using DorkNet.AdminMobile.Services;
|
||||
|
||||
namespace DorkNet.AdminMobile.Pages;
|
||||
|
||||
public sealed class RoomsPage : ContentPage
|
||||
{
|
||||
private readonly AdminApiClient api;
|
||||
private readonly CollectionView rooms = new();
|
||||
private readonly CollectionView instances = new();
|
||||
private readonly Label status = AppDesign.StatusLabel();
|
||||
private readonly Button refresh = AppDesign.SecondaryButton("Refresh");
|
||||
private bool loaded;
|
||||
|
||||
public RoomsPage(AdminApiClient api)
|
||||
{
|
||||
this.api = api;
|
||||
Title = "Rooms";
|
||||
Background = AppDesign.PageBackground;
|
||||
|
||||
rooms.SelectionMode = SelectionMode.None;
|
||||
rooms.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
|
||||
rooms.EmptyView = EmptyList("No recent rooms");
|
||||
rooms.ItemTemplate = new DataTemplate(() =>
|
||||
{
|
||||
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
|
||||
name.SetBinding(Label.TextProperty, nameof(RoomSummary.Name));
|
||||
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
|
||||
meta.SetBinding(Label.TextProperty, new Binding(nameof(RoomSummary.Id), stringFormat: "Room #{0}"));
|
||||
var row = new VerticalStackLayout
|
||||
{
|
||||
Padding = new Thickness(12, 9),
|
||||
Spacing = 2,
|
||||
BackgroundColor = AppDesign.RowSurface,
|
||||
Children = { name, meta },
|
||||
};
|
||||
return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } };
|
||||
});
|
||||
|
||||
instances.SelectionMode = SelectionMode.None;
|
||||
instances.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
|
||||
instances.EmptyView = EmptyList("No active instances");
|
||||
instances.ItemTemplate = new DataTemplate(() =>
|
||||
{
|
||||
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
|
||||
name.SetBinding(Label.TextProperty, nameof(InstanceSummary.RoomName));
|
||||
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
|
||||
meta.SetBinding(Label.TextProperty, new Binding(nameof(InstanceSummary.PhotonRegionId), stringFormat: "Region {0}"));
|
||||
var count = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
|
||||
count.SetBinding(Label.TextProperty, new Binding("Participants.Count", stringFormat: "{0} participants"));
|
||||
var row = new VerticalStackLayout
|
||||
{
|
||||
Padding = new Thickness(12, 9),
|
||||
Spacing = 2,
|
||||
BackgroundColor = AppDesign.RowSurface,
|
||||
Children = { name, meta, count },
|
||||
};
|
||||
return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } };
|
||||
});
|
||||
|
||||
refresh.Clicked += async (_, _) => await LoadAsync();
|
||||
|
||||
var grid = new Grid
|
||||
{
|
||||
Padding = 16,
|
||||
RowSpacing = 10,
|
||||
RowDefinitions =
|
||||
{
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
new RowDefinition { Height = new GridLength(1, GridUnitType.Star) },
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
new RowDefinition { Height = new GridLength(2, GridUnitType.Star) },
|
||||
new RowDefinition { Height = GridLength.Auto },
|
||||
},
|
||||
};
|
||||
grid.Add(AppDesign.Title("Rooms"), 0, 0);
|
||||
grid.Add(refresh, 0, 1);
|
||||
grid.Add(new Label { Text = "Live instances", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 2);
|
||||
grid.Add(instances, 0, 3);
|
||||
grid.Add(new Label { Text = "Recent rooms", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 4);
|
||||
grid.Add(rooms, 0, 5);
|
||||
grid.Add(status, 0, 6);
|
||||
Content = grid;
|
||||
}
|
||||
|
||||
private static Label EmptyList(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
TextColor = AppDesign.Subtle,
|
||||
HorizontalTextAlignment = TextAlignment.Center,
|
||||
VerticalTextAlignment = TextAlignment.Center,
|
||||
};
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (!loaded)
|
||||
{
|
||||
loaded = true;
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
refresh.IsEnabled = false;
|
||||
status.Text = "Loading...";
|
||||
var instanceRows = await api.GetInstancesAsync();
|
||||
var roomRows = await api.GetRoomsAsync();
|
||||
instances.ItemsSource = instanceRows;
|
||||
rooms.ItemsSource = roomRows;
|
||||
status.Text = $"{instanceRows.Count} active instances, {roomRows.Count} rooms loaded";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status.Text = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
refresh.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
using Microsoft.Maui.Controls;
|
||||
using DorkNet.AdminMobile.Design;
|
||||
using DorkNet.AdminMobile.Services;
|
||||
|
||||
namespace DorkNet.AdminMobile.Pages;
|
||||
|
||||
public sealed class SettingsPage : ContentPage
|
||||
{
|
||||
private readonly AdminApiClient api;
|
||||
private readonly SecureAdminSettings settings;
|
||||
private readonly Switch signupsDisabled = new();
|
||||
private readonly Switch globalFriends = new();
|
||||
private readonly Label status = AppDesign.StatusLabel();
|
||||
private bool loading;
|
||||
|
||||
public SettingsPage(AdminApiClient api, SecureAdminSettings settings)
|
||||
{
|
||||
this.api = api;
|
||||
this.settings = settings;
|
||||
Title = "Settings";
|
||||
Background = AppDesign.PageBackground;
|
||||
|
||||
signupsDisabled.Toggled += async (_, e) =>
|
||||
{
|
||||
if (!loading) await SaveToggleAsync(() => api.SetSignupsDisabledAsync(e.Value));
|
||||
};
|
||||
globalFriends.Toggled += async (_, e) =>
|
||||
{
|
||||
if (!loading) await SaveToggleAsync(() => api.SetGlobalFriendsAsync(e.Value));
|
||||
};
|
||||
|
||||
var refresh = AppDesign.SecondaryButton("Refresh");
|
||||
refresh.Clicked += async (_, _) => await LoadAsync();
|
||||
var logout = AppDesign.DangerButton("Clear saved session");
|
||||
logout.Clicked += (_, _) =>
|
||||
{
|
||||
settings.ClearSession();
|
||||
App.ShowLogin();
|
||||
};
|
||||
|
||||
Content = new VerticalStackLayout
|
||||
{
|
||||
Padding = 16,
|
||||
Spacing = 14,
|
||||
Children =
|
||||
{
|
||||
AppDesign.Title("Settings"),
|
||||
refresh,
|
||||
Row("Disable new account creation", signupsDisabled),
|
||||
Row("Global friends mode", globalFriends),
|
||||
logout,
|
||||
status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
loading = true;
|
||||
status.Text = "Loading...";
|
||||
var s = await api.GetSettingsAsync();
|
||||
signupsDisabled.IsToggled = s.SignupsDisabled;
|
||||
globalFriends.IsToggled = s.GlobalFriendsEnabled;
|
||||
status.Text = $"Updated {s.UpdatedAt:g}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status.Text = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveToggleAsync(Func<Task> save)
|
||||
{
|
||||
try
|
||||
{
|
||||
status.Text = "Saving...";
|
||||
await save();
|
||||
status.Text = "Saved";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status.Text = ex.Message;
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static View Row(string label, View control)
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions =
|
||||
{
|
||||
new ColumnDefinition(),
|
||||
new ColumnDefinition { Width = GridLength.Auto },
|
||||
},
|
||||
Padding = new Thickness(12, 10),
|
||||
};
|
||||
grid.Add(new Label
|
||||
{
|
||||
Text = label,
|
||||
TextColor = AppDesign.Text,
|
||||
VerticalTextAlignment = TextAlignment.Center,
|
||||
}, 0, 0);
|
||||
grid.Add(control, 1, 0);
|
||||
return AppDesign.GlassCard(grid, new Thickness(0));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/appicon"
|
||||
android:roundIcon="@mipmap/appicon_round"
|
||||
android:label="DorkNet Admin"
|
||||
android:supportsRtl="true" />
|
||||
</manifest>
|
||||
@@ -1,23 +0,0 @@
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace DorkNet.AdminMobile;
|
||||
|
||||
[Activity(
|
||||
Label = "DorkNet Admin",
|
||||
Icon = "@mipmap/appicon",
|
||||
RoundIcon = "@mipmap/appicon_round",
|
||||
MainLauncher = true,
|
||||
Exported = true,
|
||||
Theme = "@style/Maui.SplashTheme",
|
||||
ConfigurationChanges =
|
||||
ConfigChanges.ScreenSize |
|
||||
ConfigChanges.Orientation |
|
||||
ConfigChanges.UiMode |
|
||||
ConfigChanges.ScreenLayout |
|
||||
ConfigChanges.SmallestScreenSize |
|
||||
ConfigChanges.Density)]
|
||||
public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using Android.App;
|
||||
using Android.Runtime;
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.Hosting;
|
||||
|
||||
namespace DorkNet.AdminMobile;
|
||||
|
||||
[Application]
|
||||
public class MainApplication(nint handle, JniHandleOwnership ownership)
|
||||
: MauiApplication(handle, ownership)
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using Foundation;
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.Hosting;
|
||||
|
||||
namespace DorkNet.AdminMobile;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DorkNet Admin</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>net.dorknet.adminmobile</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>14.2</string>
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,11 +0,0 @@
|
||||
using UIKit;
|
||||
|
||||
namespace DorkNet.AdminMobile;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
# DorkNet Admin Mobile
|
||||
|
||||
Native Android/iOS admin app for DorkNet, built with .NET MAUI.
|
||||
|
||||
In the microservices deployment, point the app at the public admin host
|
||||
for the stack, usually `https://admin.<domain>`. Cloudflare and
|
||||
`DorkNet.Gateway` keep the public URL stable. Requests made through the
|
||||
admin host are handled by the `web` service's same-origin admin route;
|
||||
the `/api/admin/*` path family is also exposed by the moderation slice
|
||||
for non-admin-host routing.
|
||||
|
||||
The browser admin page, route map, and troubleshooting flow are
|
||||
documented in [`../docs/admin.md`](../docs/admin.md).
|
||||
|
||||
## Authentication
|
||||
|
||||
The app stores sensitive values in platform secure storage:
|
||||
|
||||
- DorkNet admin JWT from `POST /api/admin/v1/login`
|
||||
- Cloudflare Access service-token client id
|
||||
- Cloudflare Access service-token client secret
|
||||
- Optional Cloudflare Access JWT assertion
|
||||
|
||||
Every API request includes:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <dorknet-admin-jwt>
|
||||
CF-Access-Client-Id: <service-token-client-id>
|
||||
CF-Access-Client-Secret: <service-token-client-secret>
|
||||
CF-Access-Jwt-Assertion: <optional-jwt>
|
||||
```
|
||||
|
||||
The Cloudflare headers are optional, but required when the admin host is behind Cloudflare Access.
|
||||
|
||||
## Build
|
||||
|
||||
Install the .NET MAUI workloads, then build per platform:
|
||||
|
||||
```powershell
|
||||
dotnet workload install maui
|
||||
dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-android36.0
|
||||
dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-ios26.0
|
||||
```
|
||||
|
||||
The project targets Android 16 / API 36 and iOS 26 SDK. iOS device/archive builds still require Xcode 26 on macOS.
|
||||
@@ -1,71 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
|
||||
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
|
||||
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
|
||||
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
|
||||
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
<clipPath id="_clip2">
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip2)">
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
|
||||
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
||||
<clipPath id="_clip4">
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip4)">
|
||||
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
|
||||
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
|
||||
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
|
||||
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
|
||||
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
|
||||
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,71 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
|
||||
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
|
||||
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
|
||||
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
|
||||
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
<clipPath id="_clip2">
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip2)">
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
|
||||
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
||||
<clipPath id="_clip4">
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip4)">
|
||||
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
|
||||
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
|
||||
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
|
||||
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
|
||||
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
|
||||
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,71 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
|
||||
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
|
||||
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
|
||||
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
|
||||
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
<clipPath id="_clip2">
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip2)">
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
|
||||
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
||||
<clipPath id="_clip4">
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip4)">
|
||||
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
|
||||
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
|
||||
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
|
||||
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
|
||||
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
|
||||
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,71 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
|
||||
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
|
||||
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
|
||||
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
|
||||
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
<clipPath id="_clip2">
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip2)">
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
|
||||
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
||||
<clipPath id="_clip4">
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip4)">
|
||||
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
|
||||
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
|
||||
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
|
||||
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
|
||||
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
|
||||
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,147 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.AdminMobile.Models;
|
||||
|
||||
namespace DorkNet.AdminMobile.Services;
|
||||
|
||||
public sealed class AdminApiClient(SecureAdminSettings settings)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly HttpClient http = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20),
|
||||
};
|
||||
|
||||
public async Task<AdminLoginResponse> LoginAsync(string username, string password)
|
||||
{
|
||||
var result = await SendAsync<AdminLoginResponse>(
|
||||
HttpMethod.Post,
|
||||
"login",
|
||||
new { Username = username, Password = password },
|
||||
includeJwt: false);
|
||||
|
||||
await settings.SaveSessionAsync(result.AccessToken, result.RefreshToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<AdminStats> GetStatsAsync() =>
|
||||
SendAsync<AdminStats>(HttpMethod.Get, "stats");
|
||||
|
||||
public Task<List<PlayerSummary>> GetPlayersAsync(string? query = null)
|
||||
{
|
||||
var suffix = string.IsNullOrWhiteSpace(query)
|
||||
? "players?take=100"
|
||||
: $"players?take=100&query={Uri.EscapeDataString(query)}";
|
||||
return SendAsync<List<PlayerSummary>>(HttpMethod.Get, suffix);
|
||||
}
|
||||
|
||||
public Task<List<RoomSummary>> GetRoomsAsync() =>
|
||||
SendAsync<List<RoomSummary>>(HttpMethod.Get, "rooms?take=100");
|
||||
|
||||
public Task<List<InstanceSummary>> GetInstancesAsync() =>
|
||||
SendAsync<List<InstanceSummary>>(HttpMethod.Get, "instances");
|
||||
|
||||
public Task<ServerSettingsDto> GetSettingsAsync() =>
|
||||
SendAsync<ServerSettingsDto>(HttpMethod.Get, "settings");
|
||||
|
||||
public Task<ServerSettingsDto> SetSignupsDisabledAsync(bool disabled) =>
|
||||
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/signups", new { Disabled = disabled });
|
||||
|
||||
public Task<ServerSettingsDto> SetGlobalFriendsAsync(bool enabled) =>
|
||||
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/global-friends", new { Enabled = enabled });
|
||||
|
||||
public Task KickPlayerAsync(long playerId, string reason) =>
|
||||
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/kick", new { Reason = reason });
|
||||
|
||||
public Task ResetAvatarAsync(long playerId) =>
|
||||
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/avatar/reset");
|
||||
|
||||
public Task DeletePlayerAsync(long playerId, string username, string phrase, string? reason) =>
|
||||
SendAsync<object>(
|
||||
HttpMethod.Delete,
|
||||
$"players/{playerId}",
|
||||
new { ConfirmUsername = username, ConfirmPhrase = phrase, Reason = reason });
|
||||
|
||||
private async Task<T> SendAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
object? body = null,
|
||||
bool includeJwt = true)
|
||||
{
|
||||
var connection = await settings.LoadConnectionAsync();
|
||||
var url = $"{connection.BaseUrl.TrimEnd('/')}/api/admin/v1/{path.TrimStart('/')}";
|
||||
using var request = new HttpRequestMessage(method, url);
|
||||
AddCloudflareAccessHeaders(request, connection);
|
||||
|
||||
if (includeJwt)
|
||||
{
|
||||
var jwt = await settings.GetJwtAsync();
|
||||
if (!string.IsNullOrWhiteSpace(jwt))
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
}
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(body, Json);
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
using var response = await http.SendAsync(request);
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new AdminApiException((int)response.StatusCode, ExtractError(text, response.ReasonPhrase));
|
||||
|
||||
if (typeof(T) == typeof(object) || string.IsNullOrWhiteSpace(text))
|
||||
return default!;
|
||||
|
||||
return JsonSerializer.Deserialize<T>(text, Json)
|
||||
?? throw new AdminApiException(0, "Server returned an empty response.");
|
||||
}
|
||||
|
||||
private static void AddCloudflareAccessHeaders(HttpRequestMessage request, AdminConnectionSettings connection)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientId) &&
|
||||
!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientSecret))
|
||||
{
|
||||
request.Headers.SetOrReplace("CF-Access-Client-Id", connection.CloudflareAccessClientId);
|
||||
request.Headers.SetOrReplace("CF-Access-Client-Secret", connection.CloudflareAccessClientSecret);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessJwt))
|
||||
request.Headers.SetOrReplace("CF-Access-Jwt-Assertion", connection.CloudflareAccessJwt);
|
||||
}
|
||||
|
||||
private static string ExtractError(string text, string? fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return fallback ?? "Request failed.";
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
if (doc.RootElement.TryGetProperty("error", out var error))
|
||||
return error.GetString() ?? fallback ?? "Request failed.";
|
||||
if (doc.RootElement.TryGetProperty("message", out var message))
|
||||
return message.GetString() ?? fallback ?? "Request failed.";
|
||||
}
|
||||
catch { }
|
||||
return text.Length > 300 ? text[..300] : text;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AdminApiException(int statusCode, string message) : Exception(message)
|
||||
{
|
||||
public int StatusCode { get; } = statusCode;
|
||||
}
|
||||
|
||||
internal static class HeaderExtensions
|
||||
{
|
||||
public static void SetOrReplace(this HttpRequestHeaders headers, string name, string value)
|
||||
{
|
||||
headers.Remove(name);
|
||||
headers.Add(name, value);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using Microsoft.Maui.Storage;
|
||||
|
||||
namespace DorkNet.AdminMobile.Services;
|
||||
|
||||
public sealed record AdminConnectionSettings(
|
||||
string BaseUrl,
|
||||
string CloudflareAccessClientId,
|
||||
string CloudflareAccessClientSecret,
|
||||
string CloudflareAccessJwt);
|
||||
|
||||
public sealed class SecureAdminSettings
|
||||
{
|
||||
private const string BaseUrlKey = "admin.baseUrl";
|
||||
private const string JwtKey = "admin.jwt";
|
||||
private const string RefreshKey = "admin.refresh";
|
||||
private const string CfClientIdKey = "cf.access.clientId";
|
||||
private const string CfClientSecretKey = "cf.access.clientSecret";
|
||||
private const string CfJwtKey = "cf.access.jwt";
|
||||
|
||||
public async Task<AdminConnectionSettings> LoadConnectionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseUrl = await SecureStorage.GetAsync(BaseUrlKey) ?? "https://admin.rec.net";
|
||||
var cfId = await SecureStorage.GetAsync(CfClientIdKey) ?? string.Empty;
|
||||
var cfSecret = await SecureStorage.GetAsync(CfClientSecretKey) ?? string.Empty;
|
||||
var cfJwt = await SecureStorage.GetAsync(CfJwtKey) ?? string.Empty;
|
||||
return new AdminConnectionSettings(baseUrl, cfId, cfSecret, cfJwt);
|
||||
}
|
||||
catch
|
||||
{
|
||||
SecureStorage.Remove(BaseUrlKey);
|
||||
SecureStorage.Remove(CfClientIdKey);
|
||||
SecureStorage.Remove(CfClientSecretKey);
|
||||
SecureStorage.Remove(CfJwtKey);
|
||||
return new AdminConnectionSettings("https://admin.rec.net", string.Empty, string.Empty, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveConnectionAsync(AdminConnectionSettings settings)
|
||||
{
|
||||
await SecureStorage.SetAsync(BaseUrlKey, settings.BaseUrl.TrimEnd('/'));
|
||||
await SecureStorage.SetAsync(CfClientIdKey, settings.CloudflareAccessClientId.Trim());
|
||||
await SecureStorage.SetAsync(CfClientSecretKey, settings.CloudflareAccessClientSecret.Trim());
|
||||
await SecureStorage.SetAsync(CfJwtKey, settings.CloudflareAccessJwt.Trim());
|
||||
}
|
||||
|
||||
public Task<string?> GetJwtAsync() => SecureStorage.GetAsync(JwtKey);
|
||||
|
||||
public async Task SaveSessionAsync(string accessToken, string refreshToken)
|
||||
{
|
||||
await SecureStorage.SetAsync(JwtKey, accessToken);
|
||||
await SecureStorage.SetAsync(RefreshKey, refreshToken);
|
||||
}
|
||||
|
||||
public void ClearSession()
|
||||
{
|
||||
SecureStorage.Remove(JwtKey);
|
||||
SecureStorage.Remove(RefreshKey);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == '' and '$(TargetFrameworks)' == ''">net6.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.ClientMod</RootNamespace>
|
||||
<AssemblyName>DorkNet.ClientMod</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' == ''">false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' != ''">true</AppendTargetFrameworkToOutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
@@ -20,28 +19,21 @@
|
||||
they would be derivative works of the game anyway, so we never
|
||||
reference them. Only MelonLoader's own DLLs are needed.
|
||||
|
||||
By default the project downloads the pinned desktop MelonLoader release
|
||||
into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest /
|
||||
LemonLoader builds, pass:
|
||||
|
||||
-p:TargetFrameworks=net8.0
|
||||
-p:MelonLoaderDir=<path-to-LemonLoader-melon_data/MelonLoader>
|
||||
|
||||
The reference folder follows the target framework unless explicitly
|
||||
overridden with -p:MelonLoaderReferenceTfm=...
|
||||
By default the project downloads the pinned MelonLoader release
|
||||
into DorkNet.ClientMod/build/ and references those DLLs. Pass
|
||||
-p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
|
||||
deliberately want to use a local override.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
|
||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
|
||||
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
|
||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||
@@ -55,19 +47,19 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
+1021
-462
@@ -3,16 +3,9 @@
|
||||
// server:
|
||||
//
|
||||
// 1. URI rewrite — `.rec.net` → user-configured host
|
||||
// 2. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
//
|
||||
// Plus the anti-cheat BYPASS patches (anti-tamper / ToxMod / file-hash)
|
||||
// needed so a modded client isn't kicked, and two opt-in features that
|
||||
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size).
|
||||
//
|
||||
// This is the STABLE, shipping mod: it carries no diagnostic/tracing
|
||||
// code. All of that lives in the separate DorkNet.DebugMod (drop it in
|
||||
// the Mods folder to debug); the dev-menu / dev-debug-tools tooling
|
||||
// lives outside the public repo entirely.
|
||||
// 2. Photon AppId — override the baked-in Photon Cloud AppId
|
||||
// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth
|
||||
// 4. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
//
|
||||
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
|
||||
// install-melon.ps1 wrapper writes that file on install; you can also
|
||||
@@ -29,22 +22,13 @@
|
||||
// Il2CppAssemblies directory just to write the source.
|
||||
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
@@ -55,13 +39,14 @@ public class Mod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||
internal static Mod? Instance;
|
||||
internal static string? DiagnosticsLogPath;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _networkPatchesRegistered;
|
||||
private bool _antiTamperCallbackPatchesComplete;
|
||||
private int _antiTamperCallbackRetryFrame;
|
||||
private bool _toxModPatchesComplete;
|
||||
private int _toxModRetryFrame;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private int _diagnosticRetryFrame;
|
||||
private int _photonOverridePollFrame;
|
||||
|
||||
// Config backed by a plain JSON file under MelonLoader/UserData.
|
||||
// The defaults below match the recommended install, so dropping this
|
||||
@@ -69,246 +54,157 @@ public class Mod : MelonMod
|
||||
public static class Cfg
|
||||
{
|
||||
public static string ServerHost = "localhost";
|
||||
public static string SingleOriginBaseUrl = "";
|
||||
public static string PhotonAppId = "";
|
||||
public static string PhotonVoiceAppId = "";
|
||||
// Forced PhotonServerSettings.PreferredRegion (with HostType
|
||||
// switched to PhotonCloud) so two watches don't auto-ping
|
||||
// themselves into different "best" regions and end up in
|
||||
// parallel Photon rooms sharing only a name. PhotonPatches
|
||||
// .ApplyForcedRegion prefers the server-told region from
|
||||
// Matchmaking.LocalRoomInstance.PhotonRegionId when available
|
||||
// and falls back to this config value on initial boot before
|
||||
// any /goto has returned. Default matches the server's
|
||||
// appsettings.json Photon:CloudRegion ("eu").
|
||||
public static string PhotonCloudRegion = "eu";
|
||||
public static bool EnableTlsTrustBypass = true;
|
||||
|
||||
// ── Desktop Screen Sharing gadget FPS ──
|
||||
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
|
||||
// the shared desktop image. Its cadence is a baked [SerializeField]
|
||||
// float screenShareImageRefreshFrequency — the broadcast tick gates
|
||||
// on (Time.time - lastSend >= 1f / frequency), verified at
|
||||
// DesktopScreenSharingDisplay.txt:697-703, so the field IS the FPS
|
||||
// and its editor-only [Range] is NOT enforced at runtime. Set a
|
||||
// target here to override it (default prefab value is low, ~5).
|
||||
// 0 = leave the prefab value alone (patch not even registered).
|
||||
public static float DesktopScreenShareFps = 0f;
|
||||
// The captured frame ships via the gadget's IPunObservable
|
||||
// OnPhotonSerializeView, which only fires at
|
||||
// PhotonNetwork.SerializationRate (default 10 Hz) — so 30 fps capture
|
||||
// still delivers ~10 unless we raise it. This lifts SendRate +
|
||||
// SerializationRate to >= the FPS target. GLOBAL: affects every
|
||||
// PhotonView's sync traffic, so turn off if it lags a busy room.
|
||||
public static bool DesktopScreenShareRaisePhotonRate = true;
|
||||
// Optional per-frame size knobs (0 = leave the prefab value). Higher
|
||||
// FPS needs smaller frames to fit the Photon serialization budget;
|
||||
// lower these if 30 fps saturates bandwidth (chunked frames stall).
|
||||
public static int DesktopScreenShareResolution = 0; // horizontal px
|
||||
public static int DesktopScreenShareQuality = 0; // JPEG quality
|
||||
|
||||
// Raise RRO quest party size past the baked 4. The cap lives in each
|
||||
// quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize
|
||||
// ScriptableObject (baked in the client bundle; no server lever). The
|
||||
// quest scoreboard + party HUD build rows dynamically, so they
|
||||
// auto-scale once the team cap is raised. 0 = off (leave at 4).
|
||||
// Only single-team, quest-named configs are touched, so PvP team
|
||||
// sizes (paintball/laser tag) are never changed. See QuestTeamSize.
|
||||
public static int QuestMaxTeamSize = 0;
|
||||
// Only these RRO quest GameConfigurationAssets get their team size
|
||||
// bumped (matched loosely: case/space-insensitive, filler words like
|
||||
// "the/of/for/quest" ignored, so "IsleOfLostSkulls" matches the
|
||||
// in-game "Isle of the Lost Skulls"). Empty = bump nothing. The
|
||||
// generic "Quest" config is intentionally NOT in the default list.
|
||||
public static string[] QuestMaxTeamSizeRooms =
|
||||
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
|
||||
// Photon Custom Auth injector was parked 2026-05-28; see
|
||||
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
|
||||
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
|
||||
// (normally dev-gated) and silence CheatManager's tamper detectors
|
||||
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
|
||||
// drop you to the dorm. Default OFF — purely opt-in. The toggle key
|
||||
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
}
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet ClientMod loading ===");
|
||||
LoadConfig();
|
||||
Log.Msg($"DorkNet ClientMod loaded — host={Cfg.ServerHost}, tlsBypass={Cfg.EnableTlsTrustBypass}");
|
||||
RegisterNetworkPatches();
|
||||
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
|
||||
}
|
||||
|
||||
public override void OnLateInitializeMelon()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
|
||||
Log.Msg("=== Registering client patches ===");
|
||||
RegisterNetworkPatches();
|
||||
TryPatchByName("RecRoom.AntiCheat.EACManager",
|
||||
"GenerateChallengeResponse",
|
||||
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
|
||||
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
|
||||
// NetworkingPeer.CallAuthenticate hook (Photon Custom Auth
|
||||
// injector) parked — see attic/AuthValuesInjector.cs.attic.
|
||||
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
// Post-save blob-name refresh. The 2020 watch's
|
||||
// RoomPersistenceManager.RoomDataBlobName is only ever set by
|
||||
// /goto-driven flows — neither the SubscriptionUpdateRoom push
|
||||
// nor SubscriptionUpdatePresence pushes the server fires after
|
||||
// save propagate into this field. So the PostSaveReloading
|
||||
// sub-state's MasterReloadRoomAsync deserializes the cached
|
||||
// bytes from the OLD blob name (the one /goto returned at boot),
|
||||
// wiping any MakerPen object the user just placed even though
|
||||
// the new blob is already on S3.
|
||||
//
|
||||
// Verified live: after a dorm save with object placement, server
|
||||
// pushes SubscriptionUpdateRoom + PresenceUpdate both carrying
|
||||
// dataBlob="dorm_p<id>_v(N+1).dat", but the watch's
|
||||
// set_RoomDataBlobName trace immediately after the save shows
|
||||
// old="v(N)" new="v(N)" — no propagation, no fresh download,
|
||||
// no respawn. Leave+return triggers /goto which DOES update
|
||||
// RoomDataBlobName, so the fix is to do the same write
|
||||
// ourselves: when entering the SavingRoomSuccess state on the
|
||||
// RoomPersistenceManager, read the just-pushed
|
||||
// Rooms.LocalRoomScene.DataBlobName and force-set
|
||||
// __instance.RoomDataBlobName to it. That fires the real
|
||||
// setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync
|
||||
// for the new blob → PostSaveReloading picks up the fresh
|
||||
// bytes and respawns the MakerPen objects.
|
||||
TryPatchByName("RecRoom.Persistence.RoomPersistenceManager",
|
||||
"set_RoomDataBlobName",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(AntiCheatPatches.GenerateChallengeResponse_Prefix));
|
||||
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
|
||||
// Even when the prefix's return value is "let original run", the
|
||||
// detour itself still has to be JIT-installed when BouncyCastle
|
||||
// does its first handshake, and MonoMod's CompileMethodHook
|
||||
// fatal-CLRs (0x80131506) installing trampolines into these
|
||||
// third-party interface implementations. Only register the
|
||||
// detours if the bypass is actually wanted; on a setup with
|
||||
// valid certs (the default) skip the patch entirely.
|
||||
if (Cfg.EnableTlsTrustBypass)
|
||||
prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix));
|
||||
// Intercept the SubscriptionUpdateRoom push BEFORE the watch's
|
||||
// (cachedRoomId, cachedSubRoomId) early-out gate drops it. We
|
||||
// stash the just-arrived Scenes[0].DataBlobName into a static
|
||||
// so the set_RoomDataBlobName hijack can use it — otherwise
|
||||
// LocalRoomDetails / LocalRoomScene never get the new blob
|
||||
// name on the second-or-later push for the same (room, sub)
|
||||
// tuple (= every post-save push in a dorm).
|
||||
TryPatchByName("RecNet.Rooms",
|
||||
"OnSubscriptionUpdateRoom",
|
||||
prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix));
|
||||
// Force AccountExtensions.CanLocalPlayerChat to return true. The
|
||||
// 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote
|
||||
// Coroutine d__53 offset 040) gates on this — if false, the
|
||||
// coroutine bails before raising the Photon RPC, so the sender
|
||||
// doesn't even see their own message. The check ANDs three
|
||||
// signals: LocalAccount.TreatAsJunior (ObscuredBool at offset
|
||||
// 0x40), a PlatformManager flag, and a ModerationManager
|
||||
// singleton field. At least one returns the wrong value against
|
||||
// our server. Force-allow — single-tenant private server, text
|
||||
// moderation isn't a concern.
|
||||
TryPatchByName("RecNet.AccountExtensions",
|
||||
"CanLocalPlayerChat",
|
||||
args: Type.EmptyTypes,
|
||||
postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix));
|
||||
// PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat
|
||||
// font asset's character table via TMP_FontAsset.HasCharacters
|
||||
// to strip glyphs the font can't render. On 2020 Rec Room the
|
||||
// chat font field can be null at the moment this fires (font
|
||||
// asset bundle hasn't loaded yet?) and HasCharacters NREs deep
|
||||
// inside Unity's TMP — which propagates out through the chat
|
||||
// send callback and aborts the RPC. The watch's own ISIL has
|
||||
// "skipping invalid character check" log paths for missing
|
||||
// assets but doesn't actually short-circuit on the null field
|
||||
// we hit. Skip the method entirely; chat lines will just keep
|
||||
// any glyphs the font can't render (most common: emoji).
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RemoveInvalidCharactersFromMessage",
|
||||
prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix));
|
||||
// Trace hooks so we can see exactly where chat dies. Send path:
|
||||
// KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string)
|
||||
// → PlayerEmotes.SendChatEmote(msg, true)
|
||||
// → SendChatEmoteCoroutine d__53 → PurifyString HTTP call
|
||||
// → b__0 callback (error, cleanVersion) — fires Photon RPC
|
||||
// AND local-echoes via ProcessNewChatMessageReceived
|
||||
// → on each receiver (including self): RpcChatEmote(msg) →
|
||||
// ProcessNewChatMessageReceived → ReceiveRoomChat appends to
|
||||
// the chat log buffer.
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"SendChatEmote",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.SendChatEmote_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"ProcessNewChatMessageReceived",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RpcChatEmote",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
|
||||
// Debug console: skip CheatManager's tamper detectors so the
|
||||
// console's movement/time commands don't trip a dorm-drop. The
|
||||
// console UI toggle itself is driven from OnUpdate (hotkey) and
|
||||
// resolved lazily — only the anti-cheat skip needs a patch.
|
||||
if (Cfg.EnableDebugConsole)
|
||||
{
|
||||
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
}
|
||||
// Anti-tamper / ToxMod / file-hash-checker are anti-cheat BYPASS
|
||||
// patches — core, always on, or the modded client gets kicked.
|
||||
RegisterAntiTamperCallbackPatches(logMisses: true);
|
||||
RegisterToxModPatches(logMisses: true);
|
||||
PatchFileHashCheckerCallback();
|
||||
// Desktop Screen Sharing FPS override. Only register when a target
|
||||
// is set; the postfix on the gadget's Awake rewrites the baked
|
||||
// refresh-frequency field (and, if enabled, raises the Photon
|
||||
// serialization rate so the higher capture rate actually transmits).
|
||||
if (Cfg.DesktopScreenShareFps > 0f)
|
||||
{
|
||||
TryPatchByName("RecRoom.Tools.Productivity.DesktopScreenSharingDisplay",
|
||||
"Awake", args: Type.EmptyTypes,
|
||||
postfix: nameof(ScreenSharePatches.DisplayAwake_Postfix));
|
||||
}
|
||||
|
||||
// RRO quest party-size bump. Prefix the runtime config-apply path so
|
||||
// we can read the config's Name (scope to the allowlisted quests) and
|
||||
// bump GameConfigurationData.TeamConfigurations[].TeamSize before the
|
||||
// team manager / pre-game roster reads it. Only when a target is set.
|
||||
if (Cfg.QuestMaxTeamSize > 0)
|
||||
{
|
||||
// The runtime team cap is built from the baked GameConfigurationAsset
|
||||
// (a readable ScriptableObject) by GameConfigurationAsset.FIFOMGAOOHH()
|
||||
// — it reads asset.TeamConfigurations[i].MaxTeamSize (a typed int
|
||||
// struct-array) into the protobuf TeamSize. Prefix that builder and
|
||||
// bump the source array IN PLACE before it converts, scoped by
|
||||
// asset.Name. This avoids the obfuscated protobuf entirely (its
|
||||
// generic RepeatedField getter isn't emitted by Il2CppInterop).
|
||||
// Host-authoritative: the quest host must run the mod. See QuestTeamSize.
|
||||
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "FIFOMGAOOHH",
|
||||
prefix: nameof(QuestTeamSize.Build_Prefix));
|
||||
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
|
||||
TryPatchByName("GameSpawnManager", "KMPAOJGOOCH",
|
||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
|
||||
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
|
||||
Log.Msg($"[debugconsole] enabled — CheatManager detectors silenced; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
|
||||
}
|
||||
|
||||
RegisterDiagnostics();
|
||||
Log.Msg("=== Client patches registered ===");
|
||||
}
|
||||
private bool PatchFileHashCheckerCallback()
|
||||
{
|
||||
var prefixMethod = GetPatchMethod(nameof(FileHashCheckerPatches.InitializeCallback_Prefix));
|
||||
var patched = 0;
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
if (!MatchesFileHashCheckerCallbackType(type)) continue;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (!MatchesFileHashCheckerCallbackMethod(method)) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] file hash checker callback {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] file hash checker callback");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
private void RegisterAntiTamperCallbackPatches(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= PatchCallbackMethod(
|
||||
"hile warning callback",
|
||||
type => TypeNameContains(type, "FPIBGPIAOBI") && TypeNameContains(type, "KHIIHOIFDMP"),
|
||||
method => MethodNameContains(method, "CreateHileWarning") && MethodNameContains(method, "b__0"),
|
||||
nameof(AntiTamperPatches.CreateHileWarningCallback_Prefix),
|
||||
logMisses);
|
||||
complete &= PatchCallbackMethod(
|
||||
"unknown DLL callback",
|
||||
type => TypeNameContains(type, "CheatManager") && TypeNameContains(type, "PNHDGEKCJCH"),
|
||||
method => MethodNameContains(method, "OnUnknownDLLDetected") && MethodNameContains(method, "b__0"),
|
||||
nameof(AntiTamperPatches.UnknownDllDetectedCallback_Prefix),
|
||||
logMisses);
|
||||
_antiTamperCallbackPatchesComplete = complete;
|
||||
}
|
||||
private void RegisterToxModPatches(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("ToxMod.ToxModVoiceComponent",
|
||||
"CanInitializeToxMod",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(ToxModPatches.CanInitializeToxMod_Prefix),
|
||||
logMiss: logMisses);
|
||||
_toxModPatchesComplete = complete;
|
||||
}
|
||||
private bool PatchCallbackMethod(string label,
|
||||
Func<Type, bool> typePredicate,
|
||||
Func<MethodInfo, bool> methodPredicate,
|
||||
string prefix,
|
||||
bool logMiss)
|
||||
{
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
var patched = 0;
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
if (!typePredicate(type)) continue;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (!methodPredicate(method)) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {label} {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
private static bool TypeNameContains(Type type, string value)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains(value, StringComparison.Ordinal);
|
||||
}
|
||||
private static bool MethodNameContains(MethodInfo method, string value)
|
||||
=> method.Name.Contains(value, StringComparison.Ordinal);
|
||||
private static bool MatchesFileHashCheckerCallbackType(Type type)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains("BEPEAHOOLDB", StringComparison.Ordinal)
|
||||
&& fullName.Contains("PBENKLNHBHL", StringComparison.Ordinal);
|
||||
}
|
||||
private static bool MatchesFileHashCheckerCallbackMethod(MethodInfo method)
|
||||
{
|
||||
if (method.ReturnType != typeof(void)) return false;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length != 1) return false;
|
||||
|
||||
var name = method.Name;
|
||||
if (name == "<InitializeFileHashChecker>b__0") return true;
|
||||
if (name == "_InitializeFileHashChecker_b__0") return true;
|
||||
return name.Contains("InitializeFileHashChecker", StringComparison.Ordinal)
|
||||
&& name.Contains("b__0", StringComparison.Ordinal);
|
||||
}
|
||||
private void RegisterNetworkPatches()
|
||||
{
|
||||
if (_networkPatchesRegistered) return;
|
||||
@@ -329,6 +225,7 @@ public class Mod : MelonMod
|
||||
|
||||
_networkPatchesRegistered = complete;
|
||||
}
|
||||
|
||||
private bool TryPatchHttpManagerStringSendRequestOverloads()
|
||||
{
|
||||
var type = ResolveType("BestHTTP.HTTPManager");
|
||||
@@ -355,100 +252,89 @@ public class Mod : MelonMod
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
|
||||
{
|
||||
_antiTamperCallbackRetryFrame = 0;
|
||||
RegisterAntiTamperCallbackPatches(logMisses: false);
|
||||
}
|
||||
// Photon AppId override poller. The harmony hook on
|
||||
// PhotonNetwork.ConnectUsingSettings registers in IL2CPP but
|
||||
// doesn't actually fire in this build (verified in the
|
||||
// MelonLoader log — [patch-ok] appears but [photon-appid]
|
||||
// never does), so we poll until PhotonServerSettings is loaded
|
||||
// and write the AppId then. Stops calling once the override
|
||||
// succeeds. Throttled to every 6th frame (~10 Hz at 60 fps) so
|
||||
// the failure log doesn't drown the file when PhotonServerSettings
|
||||
// isn't loaded yet.
|
||||
if (!PhotonPatches.OverrideApplied && (_photonOverridePollFrame++ % 6) == 0)
|
||||
PhotonPatches.TryApplyOverride(reason: "update-poll");
|
||||
|
||||
if (!_toxModPatchesComplete && ++_toxModRetryFrame >= 300)
|
||||
{
|
||||
_toxModRetryFrame = 0;
|
||||
RegisterToxModPatches(logMisses: false);
|
||||
}
|
||||
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
|
||||
|
||||
if (_diagnosticGameComplete) return;
|
||||
if (_diagnosticRetryFrame++ > 3600) return;
|
||||
if ((_diagnosticRetryFrame % 60) != 0) return;
|
||||
|
||||
var logMisses = (_diagnosticRetryFrame % 300) == 0;
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses);
|
||||
if (_diagnosticGameComplete)
|
||||
DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered");
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnApplicationQuit");
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
private static void LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userData = ResolveUserDataDirectory();
|
||||
// Compute the UserData path ourselves rather than calling
|
||||
// MelonLoader.MelonUtils.UserDataDirectory (which is renamed
|
||||
// to MelonEnvironment.UserDataDirectory in newer 0.6.x builds
|
||||
// and shifts again in 0.7.x). Anchoring off the mod DLL's
|
||||
// location keeps this stable across versions: the DLL lives
|
||||
// in <game>/Mods/, so two dirname()'s reach the game root,
|
||||
// then MelonLoader/UserData/ is right next to it.
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
|
||||
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
Directory.CreateDirectory(userData);
|
||||
|
||||
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
|
||||
var path = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Msg($"[config] no file at {path}; using defaults (ServerHost={Cfg.ServerHost})");
|
||||
return;
|
||||
}
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var rooms = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||
rooms.Add(e.GetString()!);
|
||||
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
|
||||
}
|
||||
if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||
if (r.TryGetProperty("SingleOriginBaseUrl", out v)) Cfg.SingleOriginBaseUrl = (v.GetString() ?? "").TrimEnd('/');
|
||||
if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
|
||||
if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
|
||||
if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
|
||||
if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||
if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
// "InjectAuthValues" key in the template is now ignored —
|
||||
// see attic/AuthValuesInjector.cs.attic.
|
||||
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, SingleOrigin={(string.IsNullOrEmpty(Cfg.SingleOriginBaseUrl) ? "<off>" : Cfg.SingleOriginBaseUrl)}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
|
||||
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
|
||||
}
|
||||
}
|
||||
private static string ResolveUserDataDirectory()
|
||||
{
|
||||
foreach (var typeName in new[]
|
||||
{
|
||||
"MelonLoader.MelonEnvironment, MelonLoader",
|
||||
"MelonLoader.MelonUtils, MelonLoader",
|
||||
})
|
||||
{
|
||||
var type = Type.GetType(typeName);
|
||||
var prop = type?.GetProperty("UserDataDirectory", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop?.GetValue(null) is string value && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
|
||||
return Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
}
|
||||
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
// ── Patch dispatch helpers ────────────────────────────────────────
|
||||
private bool TryPatch(string label, Type target, string methodName,
|
||||
Type[]? args, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
Type[]? args, string? prefix = null, string? postfix = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -458,17 +344,13 @@ public class Mod : MelonMod
|
||||
: AccessTools.Method(target, methodName, args));
|
||||
if (method is null)
|
||||
{
|
||||
// `logMiss` is throttled by the retry-loop caller — once per
|
||||
// 5 seconds in OnUpdate, every time in the one-shot initial
|
||||
// RegisterDiagnostics. Without the gate the per-second retry
|
||||
// loop would spam `[patch-miss]` lines forever for any method
|
||||
// whose name drifted in a newer game build.
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}");
|
||||
Log.Warning($"[patch-miss] {label}");
|
||||
return false;
|
||||
}
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {label}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -477,6 +359,13 @@ public class Mod : MelonMod
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the game-side type by its ORIGINAL name (no Il2Cpp prefix)
|
||||
// by walking every loaded assembly. Tries the exact name first, then
|
||||
// the same name with an `Il2Cpp` prefix, then a substring scan as a
|
||||
// last resort — covers all the IL2CPP interop naming schemes the user
|
||||
// might end up with depending on the MelonLoader / Il2CppInterop
|
||||
// version installed.
|
||||
private bool TryPatchByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
@@ -488,8 +377,9 @@ public class Mod : MelonMod
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix);
|
||||
}
|
||||
|
||||
internal static Type? ResolveType(string name)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||
@@ -501,8 +391,6 @@ public class Mod : MelonMod
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return CacheResolvedType(name, t);
|
||||
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
|
||||
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return CacheResolvedType(name, prefixed);
|
||||
// For namespaced types like "RecNet.Core", also try with
|
||||
@@ -527,11 +415,13 @@ public class Mod : MelonMod
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Type CacheResolvedType(string name, Type type)
|
||||
{
|
||||
ResolvedTypeCache[name] = type;
|
||||
return type;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
|
||||
{
|
||||
try
|
||||
@@ -552,16 +442,16 @@ public class Mod : MelonMod
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesRequestedType(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
var dottedPrefixed = "Il2Cpp." + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
|
||||
if (type.FullName == requested || type.FullName == prefixed) return true;
|
||||
if (type.Name == requested || type.Name == prefixed) return true;
|
||||
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
||||
{
|
||||
if (args is null) return $"{typeName}.{methodName}";
|
||||
@@ -570,16 +460,72 @@ public class Mod : MelonMod
|
||||
names.Add(arg.Name);
|
||||
return $"{typeName}.{methodName}({string.Join(",", names)})";
|
||||
}
|
||||
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
// 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) })
|
||||
// Look in all three patch holder classes — small enough that a
|
||||
// linear scan is cheaper than per-class lookups.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(DebugConsolePatches) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
}
|
||||
throw new MissingMethodException($"patch method '{name}' not found on any holder");
|
||||
}
|
||||
|
||||
private void RegisterDiagnostics()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostics ===");
|
||||
if (!_diagnosticCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
|
||||
_diagnosticCoreRegistered = true;
|
||||
}
|
||||
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
|
||||
Log.Msg("=== Diagnostics registered ===");
|
||||
}
|
||||
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("BootSequence", "RegisterError", new[] { typeof(string) },
|
||||
prefix: nameof(DiagnosticPatches.BootSequenceRegisterError_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"UnityTimeCheat",
|
||||
"ObscuredTypeCheat",
|
||||
"DeveloperFlagCheat",
|
||||
"HeightChangeCheat",
|
||||
"AdvancedMotionCheat",
|
||||
"StreamingAssetCheat"
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticByName("AnalyticsHelper", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("SteamManager", "Awake",
|
||||
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PreLoginInitialize",
|
||||
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PostLoginInitialize",
|
||||
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
|
||||
logMiss: logMisses);
|
||||
return complete;
|
||||
}
|
||||
|
||||
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
@@ -596,85 +542,209 @@ public class Mod : MelonMod
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ScreenSharePatches
|
||||
{
|
||||
private static bool _photonRateApplied;
|
||||
// ── Patch holders ─────────────────────────────────────────────────────
|
||||
// Each holder is a static class with public static Prefix methods that
|
||||
// Harmony invokes. Kept separate from Mod for readability and so the
|
||||
// reflection lookup in GetPatchMethod stays simple.
|
||||
|
||||
public static void DisplayAwake_Postfix(object __instance)
|
||||
// ── Debug console access ───────────────────────────────────────────────
|
||||
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
|
||||
// dev console (text input + a DebugConsoleCommandConfig command table:
|
||||
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
|
||||
// …). Retail gates it behind a developer flag. This holder force-toggles
|
||||
// its UI on a hotkey and (via the OnLateInitializeMelon patch above) skips
|
||||
// CheatManager's tamper detectors so movement/time commands don't drop you
|
||||
// to the dorm.
|
||||
//
|
||||
// Everything is resolved by reflection at runtime so the SAME code works on
|
||||
// both client builds despite their differing obfuscation:
|
||||
// * The show/hide toggle is the one non-compiler-generated private
|
||||
// instance void(bool) on DebugConsole — ShowInputField on 2020.03,
|
||||
// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated
|
||||
// one, then fall back to that structural match.
|
||||
// * The live component is found via UnityEngine.Object.FindObjectOfType
|
||||
// (name-stable) rather than the SingletonMonoBehaviour<T> accessor.
|
||||
// * Execute(string) keeps its readable name on both builds (kept here as
|
||||
// a scripted-command entry point even though the hotkey drives the UI).
|
||||
internal static class DebugConsolePatches
|
||||
{
|
||||
private static bool _consoleVisible;
|
||||
private static bool _pollErrorLogged;
|
||||
private static int _probeFrame;
|
||||
private static bool _probeDone;
|
||||
|
||||
private static bool _inputResolved;
|
||||
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
|
||||
private static object? _toggleKeyValue; // boxed KeyCode enum value
|
||||
|
||||
private static bool _consoleResolved;
|
||||
private static Type? _consoleType;
|
||||
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||
private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type)
|
||||
|
||||
// Harmony prefix returning false → skips the original CheatManager
|
||||
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
var fps = Mod.Cfg.DesktopScreenShareFps;
|
||||
if (fps <= 0f || __instance is null) return;
|
||||
var t = __instance.GetType();
|
||||
|
||||
SetField(t, __instance, "screenShareImageRefreshFrequency", fps);
|
||||
if (Mod.Cfg.DesktopScreenShareResolution > 0)
|
||||
SetField(t, __instance, "screenShareImageHorizontalResolution", Mod.Cfg.DesktopScreenShareResolution);
|
||||
if (Mod.Cfg.DesktopScreenShareQuality > 0)
|
||||
SetField(t, __instance, "screenShareImageCompressionQuality", Mod.Cfg.DesktopScreenShareQuality);
|
||||
|
||||
|
||||
if (Mod.Cfg.DesktopScreenShareRaisePhotonRate && !_photonRateApplied)
|
||||
RaisePhotonRates((int)Math.Ceiling(fps));
|
||||
if (!_inputResolved) ResolveInput();
|
||||
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
|
||||
// console object actually exists, so we learn that even if the
|
||||
// hotkey never fires.
|
||||
if (!_probeDone && _probeFrame++ > 180)
|
||||
{
|
||||
_probeDone = true;
|
||||
ProbeConsole();
|
||||
}
|
||||
if (_getKeyDown is null || _toggleKeyValue is null) return;
|
||||
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
|
||||
{
|
||||
Mod.Log.Msg($"[debugconsole] toggle key '{Mod.Cfg.DebugConsoleToggleKey}' detected");
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_pollErrorLogged) { _pollErrorLogged = true; Mod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[screenshare] Awake postfix failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void SetField(Type t, object inst, string name, object value)
|
||||
// Diagnostic: reports whether a live DebugConsole component exists and
|
||||
// whether the show/hide method resolved — without needing a keypress.
|
||||
private static void ProbeConsole()
|
||||
{
|
||||
var f = AccessTools.Field(t, name);
|
||||
if (f is null) { Mod.Log.Warning($"[screenshare] field '{name}' not found on {t.Name}"); return; }
|
||||
f.SetValue(inst, value);
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||
try
|
||||
{
|
||||
var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType });
|
||||
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Lift PhotonNetwork.SendRate + SerializationRate to >= hz. PUN requires
|
||||
// SerializationRate <= SendRate, so SendRate is kept at the max of the
|
||||
// two. Both are public static int members (property on some PUN builds,
|
||||
// field on others) — handle either. GLOBAL: every PhotonView's
|
||||
// OnPhotonSerializeView now fires at this rate.
|
||||
private static void RaisePhotonRates(int hz)
|
||||
// Runs DebugConsole.Execute(command) directly — bypasses the UI/gate
|
||||
// entirely. Handy for scripted one-shots; the hotkey path uses the UI.
|
||||
public static void ExecuteCommand(string command)
|
||||
{
|
||||
var pn = Mod.ResolveType("PhotonNetwork");
|
||||
if (pn is null) { Mod.Log.Warning("[screenshare] PhotonNetwork not found; can't raise serialization rate (delivered FPS will stay ~10)"); return; }
|
||||
var send = ReadInt(pn, "SendRate");
|
||||
var ser = ReadInt(pn, "SerializationRate");
|
||||
var target = Math.Max(hz, 1);
|
||||
var newSend = Math.Max(target, Math.Max(send, ser));
|
||||
WriteInt(pn, "SendRate", newSend);
|
||||
WriteInt(pn, "SerializationRate", target);
|
||||
_photonRateApplied = true;
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var execute = AccessTools.Method(_consoleType, "Execute", new[] { typeof(string) });
|
||||
if (execute is null) { Mod.Log.Warning("[debugconsole] Execute(string) not found"); return; }
|
||||
execute.Invoke(null, new object[] { command });
|
||||
Mod.Log.Msg($"[debugconsole] executed: {command}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] execute '{command}' failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static int ReadInt(Type t, string name)
|
||||
private static void ResolveInput()
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (p is not null && p.CanRead && p.GetValue(null) is int pv) return pv;
|
||||
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (f is not null && f.GetValue(null) is int fv) return fv;
|
||||
return 0;
|
||||
_inputResolved = true;
|
||||
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
|
||||
var inputType = Mod.ResolveType("UnityEngine.Input");
|
||||
if (keyCodeType is null || inputType is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
try { _toggleKeyValue = Enum.Parse(keyCodeType, Mod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[debugconsole] '{Mod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
|
||||
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
|
||||
}
|
||||
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||
if (_getKeyDown is null) Mod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
|
||||
else Mod.Log.Msg($"[debugconsole] hotkey armed: '{Mod.Cfg.DebugConsoleToggleKey}' (poll live)");
|
||||
}
|
||||
|
||||
private static void WriteInt(Type t, string name, int value)
|
||||
private static void ResolveConsole()
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (p is not null && p.CanWrite) { p.SetValue(null, value); return; }
|
||||
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (f is not null) f.SetValue(null, value);
|
||||
_consoleResolved = true;
|
||||
_consoleType = Mod.ResolveType("RecRoom.Debugging.DebugConsole");
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
|
||||
|
||||
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
|
||||
// name, then a structural fallback that fits both builds.
|
||||
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(_consoleType);
|
||||
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
|
||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||
_findObjectOfType = objType is null ? null
|
||||
: AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) });
|
||||
if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found");
|
||||
}
|
||||
|
||||
// The console's show/hide is the single private, instance, void method
|
||||
// taking exactly one bool, excluding compiler-generated members (the
|
||||
// property backing setter on 2020.12 is also void(bool) but is marked
|
||||
// [CompilerGenerated]).
|
||||
private static MethodInfo? FindToggleByShape(Type t)
|
||||
{
|
||||
MethodInfo? found = null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||
if (found is not null) return null; // ambiguous — don't guess
|
||||
found = m;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static void Toggle()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
|
||||
try
|
||||
{
|
||||
var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType });
|
||||
if (instance is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||
return;
|
||||
}
|
||||
// Best-effort: flip the static "show button" enable so the
|
||||
// game's own Update keeps the console available instead of
|
||||
// re-hiding it. Failures are ignored (obfuscated builds).
|
||||
EnableShowButton();
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void EnableShowButton()
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
|
||||
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
|
||||
{
|
||||
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
|
||||
}
|
||||
}
|
||||
catch { /* best-effort — the toggle still works without it */ }
|
||||
}
|
||||
}
|
||||
|
||||
internal static class UriPatches
|
||||
{
|
||||
public static void HttpRequestSetUri_Prefix(ref Uri __0)
|
||||
{
|
||||
RewriteUriArg(ref __0, "httprequest-seturi-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpRequestPrepareUri_Prefix(ref Uri __0)
|
||||
{
|
||||
RewriteUriArg(ref __0, "httprequest-prepareuri-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpManagerSendRequestString_Prefix(ref string url)
|
||||
{
|
||||
RewriteString(ref url, "httpmanager-rewrite");
|
||||
@@ -699,24 +769,14 @@ internal static class UriPatches
|
||||
{
|
||||
try
|
||||
{
|
||||
if (request is null) return;
|
||||
var type = request.GetType();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (uriProp?.GetValue(request) is not Uri uri) return;
|
||||
|
||||
if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return;
|
||||
if (TryRewriteUriField(request, type.GetField("<Uri>k__BackingField", flags), label)) return;
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
|
||||
foreach (var prop in type.GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryRewriteUriProperty(request, prop, label)) return;
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryRewriteUriField(request, field, label)) return;
|
||||
}
|
||||
uriProp.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -724,51 +784,28 @@ internal static class UriPatches
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteUriArg(ref Uri uri, string label)
|
||||
{
|
||||
if (uri is null) return;
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
|
||||
uri = rewritten;
|
||||
}
|
||||
|
||||
private static bool TryRewriteUriProperty(object request, PropertyInfo? prop, string label)
|
||||
{
|
||||
if (prop is null || !prop.CanRead || !prop.CanWrite) return false;
|
||||
if (prop.GetIndexParameters().Length != 0) return false;
|
||||
if (prop.GetValue(request) is not Uri uri) return false;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return false;
|
||||
|
||||
prop.SetValue(request, rewritten);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryRewriteUriField(object request, FieldInfo? field, string label)
|
||||
{
|
||||
if (field is null || field.IsInitOnly) return false;
|
||||
if (field.GetValue(request) is not Uri uri) return false;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return false;
|
||||
|
||||
field.SetValue(request, rewritten);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void RewriteString(ref string uriString, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uriString)) return;
|
||||
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) return;
|
||||
|
||||
var rewrittenUri = RewriteUri(uri);
|
||||
if (rewrittenUri is null) return;
|
||||
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
|
||||
Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
|
||||
{
|
||||
var rewrittenUri = RewriteUri(uri);
|
||||
if (rewrittenUri is null) return;
|
||||
var rewrittenText = rewrittenUri.ToString();
|
||||
if (rewrittenText == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewrittenText}");
|
||||
uriString = rewrittenText;
|
||||
return;
|
||||
}
|
||||
|
||||
var rewritten = rewrittenUri.ToString();
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return;
|
||||
var rewritten = uriString.Replace(".rec.net", "." + host);
|
||||
if (rewritten == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||
uriString = rewritten;
|
||||
}
|
||||
|
||||
@@ -781,48 +818,463 @@ internal static class UriPatches
|
||||
var service = uri.Host[..^".rec.net".Length];
|
||||
if (string.IsNullOrWhiteSpace(service)) return null;
|
||||
|
||||
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
|
||||
Uri.TryCreate(Mod.Cfg.SingleOriginBaseUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
var path = CombinePath(baseUri.AbsolutePath, "__dn/" + service, uri.AbsolutePath);
|
||||
return new UriBuilder(baseUri)
|
||||
{
|
||||
Path = path,
|
||||
Query = uri.Query.TrimStart('?'),
|
||||
}.Uri;
|
||||
}
|
||||
|
||||
return new UriBuilder(uri)
|
||||
{
|
||||
Host = service + "." + host,
|
||||
}.Uri;
|
||||
}
|
||||
}
|
||||
internal static class AntiCheatPatches
|
||||
{
|
||||
public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result)
|
||||
|
||||
private static string CombinePath(params string[] parts)
|
||||
{
|
||||
__result = "AAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
return false;
|
||||
var cleaned = new List<string>();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var p = (part ?? "").Trim('/');
|
||||
if (p.Length > 0) cleaned.Add(p);
|
||||
}
|
||||
return "/" + string.Join("/", cleaned);
|
||||
}
|
||||
}
|
||||
internal static class AntiTamperPatches
|
||||
|
||||
internal static class PhotonPatches
|
||||
{
|
||||
public static bool CreateHileWarningCallback_Prefix(bool shouldQuit)
|
||||
/// <summary>True once <see cref="TryApplyOverride"/> has successfully
|
||||
/// written the user's AppId into <c>PhotonServerSettings</c>. The
|
||||
/// per-frame poller in <see cref="Mod.OnUpdate"/> stops calling once
|
||||
/// this flips true.</summary>
|
||||
public static bool OverrideApplied;
|
||||
|
||||
public static void PhotonAppIdOverride_Prefix()
|
||||
{
|
||||
return false;
|
||||
// Kept as a belt-and-braces hook in case the watch's actual
|
||||
// connect path does happen to flow through ConnectUsingSettings
|
||||
// (it doesn't, in March's IL2CPP build — verified via log). The
|
||||
// poller in OnUpdate is what actually gets the override in
|
||||
// before the wire connect.
|
||||
TryApplyOverride(reason: "prefix");
|
||||
}
|
||||
|
||||
public static bool UnknownDllDetectedCallback_Prefix()
|
||||
/// <summary>Tracks how many polling attempts we've logged so the
|
||||
/// "type not found yet" line doesn't drown the log file. Cleared
|
||||
/// once the override succeeds.</summary>
|
||||
private static int _missLogCount;
|
||||
|
||||
/// <summary>Mutates <see cref="PhotonServerSettings.AppID"/> +
|
||||
/// <c>VoiceAppID</c> + region with the values from
|
||||
/// <see cref="Mod.Cfg"/>. Returns true once the override has been
|
||||
/// written successfully; subsequent calls become no-ops. Returns
|
||||
/// false (and logs nothing on most retries) when PhotonServerSettings
|
||||
/// hasn't been loaded by the watch yet — the poller retries every
|
||||
/// frame until the ScriptableObject shows up.</summary>
|
||||
public static bool TryApplyOverride(string reason)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
internal static class FileHashCheckerPatches
|
||||
{
|
||||
public static bool InitializeCallback_Prefix(object? result)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
internal static class ToxModPatches
|
||||
{
|
||||
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
||||
{
|
||||
__result = false;
|
||||
return false;
|
||||
if (OverrideApplied) return true;
|
||||
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return false;
|
||||
try
|
||||
{
|
||||
// Use Mod.ResolveType (the same resolver TryPatchByName uses)
|
||||
// — it falls back to a full assembly scan after the prefix
|
||||
// attempts, which is how it finds the IL2CPP-mangled types
|
||||
// that the local FindGameType helper misses.
|
||||
var photonNetwork = Mod.ResolveType("PhotonNetwork");
|
||||
if (photonNetwork is null)
|
||||
{
|
||||
// Log the miss only on the first call + once every ~10s
|
||||
// after that, to avoid the log spam previous build hit.
|
||||
if (_missLogCount == 0 || _missLogCount % 300 == 0)
|
||||
Mod.Log.Msg($"[photon-appid] PhotonNetwork type not found yet (#{_missLogCount}, {reason})");
|
||||
_missLogCount++;
|
||||
return false;
|
||||
}
|
||||
var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
|
||||
var settings = settingsProp?.GetValue(null);
|
||||
if (settings is null) return false; // ScriptableObject not loaded yet; quietly retry next frame.
|
||||
|
||||
var settingsType = settings.GetType();
|
||||
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
|
||||
var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId");
|
||||
|
||||
var newId = Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
|
||||
|
||||
appIdProp?.SetValue(settings, newId);
|
||||
voiceProp?.SetValue(settings, newVoice);
|
||||
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice} ({reason})");
|
||||
|
||||
// Force a deterministic Photon region so two watches running
|
||||
// this mod end up on the same Photon master and "JoinByName"
|
||||
// produces ONE shared room (not two parallel ones in eu/us
|
||||
// because each watch auto-picked a different best region).
|
||||
//
|
||||
// The 2020 watch's default HostType is BestRegion(4) — every
|
||||
// launch pings all Photon Cloud regions and picks the lowest
|
||||
// latency. Two players geographically apart hit different
|
||||
// "best" regions; same room name, two regions = two rooms.
|
||||
// Server-side our matchmaking response carries
|
||||
// `photonRegionId` but the watch's Photon code never reads
|
||||
// it; the only consumer is Matchmaking.SetPhotonRegionPings
|
||||
// which only reports pings *back* to the server.
|
||||
//
|
||||
// Strategy: switch HostType to PhotonCloud(1) and set
|
||||
// PreferredRegion. Prefer the server-told region (the most
|
||||
// recent Matchmaking.LocalRoomInstance.PhotonRegionId, if
|
||||
// any) over the config default — that way a server-side
|
||||
// region change propagates without redeploying the mod.
|
||||
ApplyForcedRegion(settings, settingsType);
|
||||
OverrideApplied = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[photon-appid] override failed ({reason}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
|
||||
/// and writes <c>PreferredRegion</c>. Honors any region the server
|
||||
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
|
||||
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
|
||||
/// instance has been received yet (initial boot).</summary>
|
||||
private static void ApplyForcedRegion(object settings, Type settingsType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hostTypeProp = settingsType.GetField("HostType")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType");
|
||||
var preferredProp = settingsType.GetField("PreferredRegion")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion");
|
||||
if (hostTypeProp is null || preferredProp is null)
|
||||
{
|
||||
Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// ServerSettings.HostingOption.PhotonCloud = 1 (verified in
|
||||
// Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum
|
||||
// type from the field/property so we don't depend on a
|
||||
// specific Il2Cpp proxy namespace.
|
||||
Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)hostTypeProp).PropertyType;
|
||||
Type? regionEnum = (preferredProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)preferredProp).PropertyType;
|
||||
|
||||
// Prefer server-provided region over config. We read it via
|
||||
// the same reflection style as the existing patches so the
|
||||
// Il2Cpp type-name suffix doesn't bite us.
|
||||
string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us";
|
||||
|
||||
object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud
|
||||
object regionValue;
|
||||
try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us");
|
||||
regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1
|
||||
region = "us";
|
||||
}
|
||||
|
||||
if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue);
|
||||
else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue);
|
||||
if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue);
|
||||
else ((PropertyInfo)preferredProp).SetValue(settings, regionValue);
|
||||
|
||||
Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Reads
|
||||
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> as a
|
||||
/// lowercase enum name (eu, us, asia, …). Returns null if no room
|
||||
/// instance has been received yet, or if any link in the chain is
|
||||
/// null. Server-told region wins over the static config default
|
||||
/// because the server is authoritative about which Photon region
|
||||
/// hosts the room the watch is about to join.</summary>
|
||||
private static string? ReadServerProvidedRegion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var matchmaking = FindGameType("RecNet.Matchmaking");
|
||||
var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localRoom is null) return null;
|
||||
var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom);
|
||||
if (regionObj is null) return null;
|
||||
// CloudRegionCode enum.ToString() gives the lowercase region
|
||||
// name (eu, us, asia, …) — see CloudRegionCode.cs.
|
||||
return regionObj.ToString();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// AuthValuesInjector_Prefix + GetCurrentLockToken parked 2026-05-28
|
||||
// — see attic/AuthValuesInjector.cs.attic. Photon Custom Auth is
|
||||
// now disabled at the AppId level in the Photon dashboard, so the
|
||||
// watch no longer needs to attach userid+LoginLock to Photon
|
||||
// Authenticate ops.
|
||||
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
|
||||
internal static class SavePatches
|
||||
{
|
||||
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
|
||||
// our currently-loaded room. Captured BEFORE the watch's caching
|
||||
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
|
||||
// (roomId, subRoomId) tuple — which is every post-save push for a
|
||||
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
|
||||
// would-be no-op setter call into a real transition.
|
||||
private static volatile string? s_lastPushedBlobName;
|
||||
private static long s_lastPushedRoomId;
|
||||
private static long s_lastPushedSubRoomId;
|
||||
|
||||
// Hook on Notifications.OnNotificationReceived(string json). Fires for
|
||||
// every SignalR message before any handler dispatch. Parses the JSON
|
||||
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
|
||||
public static void OnNotificationReceived_Prefix(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
// Fast reject: only parse JSON if it could plausibly be a
|
||||
// SubscriptionUpdateRoom payload. Saves cycles on the (very
|
||||
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
|
||||
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
|
||||
return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!root.TryGetProperty("Id", out var idEl) ||
|
||||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
|
||||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
|
||||
return;
|
||||
if (!root.TryGetProperty("Msg", out var msg) ||
|
||||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!msg.TryGetProperty("Room", out var room) ||
|
||||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
long roomId = 0;
|
||||
if (room.TryGetProperty("RoomId", out var ridEl) &&
|
||||
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
roomId = ridEl.GetInt64();
|
||||
if (!msg.TryGetProperty("Scenes", out var scenes) ||
|
||||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
|
||||
scenes.GetArrayLength() == 0) return;
|
||||
var first = scenes[0];
|
||||
long subRoomId = 0;
|
||||
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
|
||||
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
subRoomId = sidEl.GetInt64();
|
||||
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
|
||||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
|
||||
var blob = blobEl.GetString();
|
||||
if (string.IsNullOrEmpty(blob)) return;
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = blob;
|
||||
Mod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void OnSubscriptionUpdateRoom_Prefix(object message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message is null) return;
|
||||
var dict = message;
|
||||
|
||||
// The watch parses this dict as Dictionary<string, object>
|
||||
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
|
||||
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
|
||||
// get_Item(key) — call it via reflection.
|
||||
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (getItem is null) return;
|
||||
|
||||
// The push wire shape from server (BuildRoomDetails) is
|
||||
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
|
||||
// on the Room sub-object; the scene's blob on Scenes[0].
|
||||
object? roomObj = TryGet(dict, getItem, "Room");
|
||||
object? scenesObj = TryGet(dict, getItem, "Scenes");
|
||||
if (roomObj is null || scenesObj is null) return;
|
||||
|
||||
long roomId = 0;
|
||||
long subRoomId = 0;
|
||||
try
|
||||
{
|
||||
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (roomItem is not null)
|
||||
{
|
||||
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
|
||||
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* roomId stays 0 — we'll log it and bail later */ }
|
||||
|
||||
// Scenes is an IL2CPP List<RoomScene>. Take the first
|
||||
// (current dorm has one scene; multi-scene rooms target
|
||||
// Scenes[currentSubRoomId] specifically, but the post-save
|
||||
// push always carries the scene that was just saved).
|
||||
object? firstScene = null;
|
||||
try
|
||||
{
|
||||
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
|
||||
}
|
||||
catch { /* leave firstScene null */ }
|
||||
if (firstScene is null) return;
|
||||
|
||||
string? newBlob = null;
|
||||
try
|
||||
{
|
||||
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (sceneItem is not null)
|
||||
{
|
||||
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
|
||||
newBlob = blob?.ToString();
|
||||
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
|
||||
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* leave newBlob null */ }
|
||||
|
||||
if (string.IsNullOrEmpty(newBlob)) return;
|
||||
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = newBlob;
|
||||
Mod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object? TryGet(object dict, MethodInfo getItem, string key)
|
||||
{
|
||||
try { return getItem.Invoke(dict, new object[] { key }); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
|
||||
// path only when the new value differs from the cached field. The
|
||||
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
|
||||
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
|
||||
// save it calls the setter with the OLD blob — a no-op that skips
|
||||
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
|
||||
// pointed at the boot-time bytes, and PostSaveReloading deserializes
|
||||
// those over the live world — wiping any MakerPen object the user
|
||||
// just placed.
|
||||
//
|
||||
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
|
||||
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
|
||||
// after b__4 returns True, even though the SubscriptionUpdateRoom
|
||||
// push already carried Scenes[0].DataBlobName="v11".
|
||||
//
|
||||
// Strategy: intercept the setter. If the incoming value matches the
|
||||
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
|
||||
// newer version (the freshly-pushed one), substitute that into the
|
||||
// value parameter so the real setter sees a transition and fires
|
||||
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
|
||||
// Falls through silently when no override is needed.
|
||||
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
|
||||
{
|
||||
string currentForLog = "<unread>";
|
||||
string sceneForLog = "<unread>";
|
||||
string scenes0ForLog = "<unread>";
|
||||
try
|
||||
{
|
||||
if (__instance is null)
|
||||
{
|
||||
Mod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var rpmType = __instance.GetType();
|
||||
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (blobNameProp is null)
|
||||
{
|
||||
Mod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
|
||||
return;
|
||||
}
|
||||
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
|
||||
|
||||
var roomsType = FindGameType("RecNet.Rooms");
|
||||
if (roomsType is null)
|
||||
{
|
||||
Mod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var localScene = roomsType.GetProperty("LocalRoomScene",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localScene is not null)
|
||||
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
|
||||
|
||||
var localDetails = roomsType.GetProperty("LocalRoomDetails",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localDetails is not null)
|
||||
{
|
||||
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
|
||||
if (scenes is not null)
|
||||
{
|
||||
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
var first = indexer?.Invoke(scenes, new object[] { 0 });
|
||||
if (first is not null)
|
||||
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional trace of EVERYTHING we read so we can see in
|
||||
// the log exactly why we did or didn't override.
|
||||
Mod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
|
||||
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
|
||||
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
|
||||
|
||||
// Pick a candidate "newer" blob name. Priority order:
|
||||
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
|
||||
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
|
||||
// cache gate drops the push).
|
||||
// 2. LocalRoomScene.DataBlobName.
|
||||
// 3. Scenes[0].DataBlobName.
|
||||
string? pushedBlob = s_lastPushedBlobName;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
|
||||
|
||||
if (string.IsNullOrEmpty(pushedBlob)) return;
|
||||
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
|
||||
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
|
||||
|
||||
Mod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
|
||||
value = pushedBlob;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
|
||||
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
|
||||
}
|
||||
}
|
||||
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
|
||||
internal static class TlsPatches
|
||||
{
|
||||
// Both BouncyCastle TLS authenticators have the same `void
|
||||
@@ -835,3 +1287,110 @@ internal static class TlsPatches
|
||||
return !Mod.Cfg.EnableTlsTrustBypass; // false skips original; true (bypass off) lets it run
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DiagnosticPatches
|
||||
{
|
||||
public static void Write(string message)
|
||||
{
|
||||
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
|
||||
try { Mod.Log.Msg(line); } catch { }
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Mod.DiagnosticsLogPath))
|
||||
File.AppendAllText(Mod.DiagnosticsLogPath, line + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void BootSequenceRegisterError_Prefix(string error)
|
||||
{
|
||||
Write($"[boot-error] BootSequence.RegisterError: {error}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void NamedMethod_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ChatPatches
|
||||
{
|
||||
private static bool s_logged;
|
||||
|
||||
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
|
||||
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
|
||||
// before raising the in-room Photon chat RPC; if it returns false
|
||||
// the coroutine bails silently and the sender doesn't even see
|
||||
// their own message echo.
|
||||
public static void CanLocalPlayerChat_Postfix(ref bool __result)
|
||||
{
|
||||
if (!__result && !s_logged)
|
||||
{
|
||||
s_logged = true;
|
||||
Mod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
|
||||
}
|
||||
__result = true;
|
||||
}
|
||||
|
||||
// Skip the original. Returning false from a Harmony prefix stops
|
||||
// the underlying call.
|
||||
public static bool RemoveInvalidCharactersFromMessage_Prefix()
|
||||
{
|
||||
Mod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void SendChatEmote_Prefix(string message)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
|
||||
}
|
||||
|
||||
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
|
||||
}
|
||||
|
||||
public static void RpcChatEmote_Prefix(string message)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
// Raise RRO quest party size past the baked 4 — precisely, only for the
|
||||
// allowlisted quests (Cfg.QuestMaxTeamSizeRooms).
|
||||
//
|
||||
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL; names
|
||||
// re-verified against the March-2023 dump — 2023 obfuscated names below):
|
||||
// GameConfigurationTool.Awake()
|
||||
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset,
|
||||
// a Unity ScriptableObject)
|
||||
// → calls GameConfigurationAsset.FIFOMGAOOHH() ← builds the runtime
|
||||
// protobuf GameConfigurationData (GPIGCAJICEF) FROM the asset
|
||||
// → stores and applies it to the team manager.
|
||||
// Inside FIFOMGAOOHH the build loop reads asset.TeamConfigurations[i]
|
||||
// .MaxTeamSize (a typed int struct-array) and writes each straight into a
|
||||
// new GameTeamConfigurationData.TeamSize.
|
||||
//
|
||||
// So the runtime cap originates from the asset's `TeamConfiguration[]` — a
|
||||
// READABLE, typed struct array — NOT from anything that needs the obfuscated
|
||||
// protobuf. Earlier attempts failed because:
|
||||
// • bumping the asset on a 2s poll happened AFTER the builder already built &
|
||||
// cached the protobuf (timing, not the wrong field);
|
||||
// • patching the protobuf getters is impossible — Il2CppInterop doesn't emit
|
||||
// the generic RepeatedField<T> getter, and the simple int getter wasn't
|
||||
// emitted either.
|
||||
//
|
||||
// THE FIX: Harmony-PREFIX GameConfigurationAsset.FIFOMGAOOHH() and bump the
|
||||
// asset's TeamConfigurations[i].MaxTeamSize to the target IN PLACE, right
|
||||
// before it builds the protobuf. The built config then carries the new cap and
|
||||
// the team manager / pre-game roster / open-slots all honour it. Scoped by
|
||||
// asset.Name to the allowlist (PvP/sandbox configs are left alone).
|
||||
//
|
||||
// HOST-AUTHORITATIVE: the quest host builds the config it networks to joiners
|
||||
// (RpcDeserializeConfig), so the host must run this mod for everyone to see the
|
||||
// raised cap. (A joiner can't override — that would need the unreachable
|
||||
// protobuf path.)
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace DorkNet.ClientMod;
|
||||
|
||||
internal static class QuestTeamSize
|
||||
{
|
||||
private const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
// 2023 spawn path (verified in the March-2023 ISIL): the spawn driver
|
||||
// HCPLAILIHEL reads the team-player index from the roster entry
|
||||
// (GameTeamManager/BNBIBPFPJPJ.JPIHHGBLLNG()) and passes it straight into
|
||||
// the KMPAOJGOOCH spawn-point filter. December's intermediate helpers are
|
||||
// gone from this path — the out-param resolver is inlined (zero call
|
||||
// sites), and GetTeamPlayerIndex is only called by Charades/RecRally, so
|
||||
// normalizing it would touch non-quest games. The KMPAOJGOOCH index
|
||||
// argument is the single choke point: normalize it in a prefix and keep
|
||||
// the empty-filter relax postfix as the backstop.
|
||||
public static bool TryPatchSpawnIndexNormalizers(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
return TryPatchRequiredSpawnIndexNormalizer(harmony);
|
||||
}
|
||||
|
||||
private static bool TryPatchRequiredSpawnIndexNormalizer(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var spawnType = Mod.ResolveType("GameSpawnManager");
|
||||
var indexType = Mod.ResolveType("NBCOFGIKKHM");
|
||||
if (spawnType is null || indexType is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.KMPAOJGOOCH index normalizer: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var target = AccessTools.Method(spawnType, "KMPAOJGOOCH");
|
||||
if (target is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.KMPAOJGOOCH index normalizer");
|
||||
return false;
|
||||
}
|
||||
|
||||
var patch = typeof(QuestTeamSize)
|
||||
.GetMethod(nameof(NormalizeRequiredSpawnIndex_Prefix), BindingFlags.Public | BindingFlags.Static)!
|
||||
.MakeGenericMethod(indexType);
|
||||
harmony.Patch(target, prefix: new HarmonyMethod(patch));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Error($"[patch-fail] GameSpawnManager.KMPAOJGOOCH index normalizer: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// PREFIX on GameSpawnManager.KMPAOJGOOCH(..., NBCOFGIKKHM index, ...).
|
||||
// The RRO quest scenes only have spawn points for indexes 0-3. After
|
||||
// raising the quest team cap, players 5+ legitimately receive INDEX_4+,
|
||||
// but those values cannot match any baked quest spawn point. Normalize
|
||||
// just the index the spawn-point filter compares against; the actual team
|
||||
// roster/cap remains raised.
|
||||
public static void NormalizeRequiredSpawnIndex_Prefix<T>(ref T __2) where T : struct
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||
|
||||
var value = Convert.ToInt32(__2);
|
||||
if (!TryNormalizeIndex(value, out var normalized)) return;
|
||||
|
||||
__2 = (T)Enum.ToObject(typeof(T), normalized);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[questsize] required spawn index normalize failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryNormalizeIndex(int value, out int normalized)
|
||||
{
|
||||
normalized = value % 4;
|
||||
return value >= 4;
|
||||
}
|
||||
|
||||
// PREFIX on GameConfigurationAsset.FIFOMGAOOHH() (asset → protobuf builder).
|
||||
public static void Build_Prefix(object __instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = Mod.Cfg.QuestMaxTeamSize;
|
||||
if (target <= 0 || __instance is null) return;
|
||||
|
||||
var name = GetMember(__instance, "Name") as string ?? "";
|
||||
if (!IsAllowed(name)) return;
|
||||
|
||||
var arr = GetMember(__instance, "TeamConfigurations");
|
||||
int len = ArrLen(arr);
|
||||
if (len <= 0) return;
|
||||
|
||||
int bumped = 0;
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var elem = ArrItem(arr, i); // boxed TeamConfiguration struct (copy)
|
||||
if (elem is null) continue;
|
||||
int cur = ReadInt(elem, "MaxTeamSize");
|
||||
if (cur > 0 && cur < target &&
|
||||
WriteInt(elem, "MaxTeamSize", target) &&
|
||||
SetArrItem(arr!, i, elem)) // write the mutated struct back
|
||||
bumped++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[questsize] build prefix failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// POSTFIX on GameSpawnManager.KMPAOJGOOCH(..., outputList). That method
|
||||
// filters spawn points by state/game-mode/spectator/team/team-player-index/
|
||||
// custom-tag before the spawn selector runs. The RRO quest prefabs only
|
||||
// have indexed start points for players 0-3; once we raise the team size,
|
||||
// player index 4+ can filter every point out before the built-in overlap
|
||||
// fallback has a chance to help.
|
||||
//
|
||||
// The caller (2023: HCPLAILIHEL) runs this filter, then if output.Count
|
||||
// is still 0 it logs "due to spawn point requirement tests" and falls back
|
||||
// to the unfiltered source list as an arbitrary spawn set. Filling the
|
||||
// output list here keeps the normal overlap pass and spawn-selection mode
|
||||
// alive instead of taking that bad arbitrary fallback.
|
||||
//
|
||||
// Harmony binds __0 and __7 by argument position: source list and output
|
||||
// list. The obfuscated parameter names are not reliable in MelonLoader's
|
||||
// generated IL2CPP metadata, which is why this intentionally avoids them.
|
||||
// If the filtered output is empty, copy the already-selected candidate
|
||||
// scene spawn list back into the output so extra players reuse baked quest
|
||||
// spawn points instead of being left out in the void.
|
||||
public static void RelaxEmptySpawnFilter_Postfix(object __0, object __7)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||
if (Count(__7) > 0) return;
|
||||
|
||||
var sourceCount = Count(__0);
|
||||
if (sourceCount <= 0) return;
|
||||
|
||||
CopyListItems(__0, __7);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[questsize] spawn filter relax failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── allowlist matching (normalize: lowercase-alnum, strip filler) ──────
|
||||
private static List<string>? _wanted;
|
||||
|
||||
private static bool IsAllowed(string configName)
|
||||
{
|
||||
_wanted ??= BuildWanted();
|
||||
if (_wanted.Count == 0) return false;
|
||||
var have = Norm(configName);
|
||||
foreach (var w in _wanted)
|
||||
if (have.Contains(w)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<string> BuildWanted()
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var entry in Mod.Cfg.QuestMaxTeamSizeRooms)
|
||||
{
|
||||
var nrm = Norm(entry);
|
||||
if (nrm.Length > 0) list.Add(nrm);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static string Norm(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return "";
|
||||
var sb = new System.Text.StringBuilder(s.Length);
|
||||
foreach (var c in s)
|
||||
if (char.IsLetterOrDigit(c)) sb.Append(char.ToLowerInvariant(c));
|
||||
return sb.ToString().Replace("the", "").Replace("for", "").Replace("of", "");
|
||||
}
|
||||
|
||||
// ── reflection helpers (Il2CppInterop exposes il2cpp members as either a
|
||||
// property or a field depending on version — try both) ────────────────
|
||||
private static object? GetMember(object o, string name)
|
||||
{
|
||||
var t = o.GetType();
|
||||
var p = t.GetProperty(name, BF);
|
||||
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
{ try { return p.GetValue(o); } catch { } }
|
||||
var f = t.GetField(name, BF);
|
||||
if (f != null) { try { return f.GetValue(o); } catch { } }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ReadInt(object? o, string name)
|
||||
{
|
||||
if (o is null) return 0;
|
||||
var v = GetMember(o, name);
|
||||
if (v is int n) return n;
|
||||
return v != null && int.TryParse(v.ToString(), out var p) ? p : 0;
|
||||
}
|
||||
|
||||
private static bool WriteInt(object o, string name, int val)
|
||||
{
|
||||
var t = o.GetType();
|
||||
var p = t.GetProperty(name, BF);
|
||||
if (p != null && p.CanWrite) { try { p.SetValue(o, val); return true; } catch { } }
|
||||
var f = t.GetField(name, BF);
|
||||
if (f != null) { try { f.SetValue(o, val); return true; } catch { } }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int ArrLen(object? arr)
|
||||
{
|
||||
if (arr is null) return 0;
|
||||
if (arr is Array a) return a.Length;
|
||||
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||
return (lp?.GetValue(arr) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static int Count(object? collection) => ArrLen(collection);
|
||||
|
||||
private static object? ArrItem(object? arr, int i)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.GetValue(i);
|
||||
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return gi?.Invoke(arr, new object[] { i });
|
||||
}
|
||||
|
||||
// Write a (boxed) struct element back into an Il2CppStructArray via its
|
||||
// set_Item(int, T) indexer. Reflection unboxes to T on invoke.
|
||||
private static bool SetArrItem(object arr, int i, object val)
|
||||
{
|
||||
if (arr is Array a) { try { a.SetValue(val, i); return true; } catch { return false; } }
|
||||
foreach (var m in arr.GetType().GetMethods())
|
||||
{
|
||||
if (m.Name != "set_Item") continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length == 2 && ps[0].ParameterType == typeof(int))
|
||||
{
|
||||
try { m.Invoke(arr, new[] { i, val }); return true; } catch { }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int CopyListItems(object? src, object? dst)
|
||||
{
|
||||
if (src is null || dst is null) return 0;
|
||||
|
||||
MethodInfo? add = null;
|
||||
foreach (var method in dst.GetType().GetMethods())
|
||||
{
|
||||
if (method.Name != "Add") continue;
|
||||
if (method.GetParameters().Length != 1) continue;
|
||||
add = method;
|
||||
break;
|
||||
}
|
||||
if (add is null) return 0;
|
||||
|
||||
var n = ArrLen(src);
|
||||
var copied = 0;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var item = ArrItem(src, i);
|
||||
if (item is null) continue;
|
||||
try
|
||||
{
|
||||
add.Invoke(dst, new[] { item });
|
||||
copied++;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
private static string TypeName(object? value) =>
|
||||
value?.GetType().FullName ?? "<null>";
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// AuthValuesInjector — PARKED 2026-05-28
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
|
||||
// doesn't pick it up. Don't rename to .cs unless you're putting the
|
||||
// feature back.
|
||||
//
|
||||
// What this used to do
|
||||
// --------------------
|
||||
// Prefix-patched NetworkingPeer.CallAuthenticate so the 2020 watch
|
||||
// would attach a Photon Custom-Auth payload (`userid` +
|
||||
// `LoginLock`) to every Photon Authenticate op. The server's
|
||||
// /photon/customauth controller (PhotonCustomAuthController.cs) read
|
||||
// those parameters and validated them against the player's
|
||||
// /player/login lock.
|
||||
//
|
||||
// Why it's parked
|
||||
// ---------------
|
||||
// Photon Cloud's Custom Auth probe was getting 403'd at the WAF in
|
||||
// front of `auth.<apex>/photon/customauth` for hosts behind
|
||||
// Cloudflare or Tunnelto's proxy. The Photon NameServer then returned
|
||||
// `CustomAuthenticationFailed (ReturnCode 32755)` and the watch
|
||||
// bounced back to the dorm before a session could start.
|
||||
//
|
||||
// The cleaner fix turned out to be:
|
||||
// 1. Turn off Custom Authentication on the Photon AppId in the
|
||||
// dashboard (or flip "Anonymous connect" to Allow), so Photon
|
||||
// never calls our URL.
|
||||
// 2. Stop sending custom-auth params from the client — that's the
|
||||
// InjectAuthValues=false flag we shipped first. With the
|
||||
// dashboard side off too, the injector code below has nothing
|
||||
// to gate on, so it's removed from the live patch.
|
||||
//
|
||||
// How to put it back
|
||||
// ------------------
|
||||
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic
|
||||
// suffix). It compiles as part of DorkNet.ClientMod.
|
||||
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the
|
||||
// config-load line in LoadConfig.
|
||||
// 3. Re-add the harmony patch hook in OnLateInitializeMelon:
|
||||
// TryPatchByName("NetworkingPeer", "CallAuthenticate",
|
||||
// prefix: nameof(PhotonPatches.AuthValuesInjector_Prefix));
|
||||
// 4. Flip `"InjectAuthValues": true` in
|
||||
// DorkNet.ClientMod/dorknet-clientmod.json.template (and the
|
||||
// copy under local-releases/).
|
||||
// 5. If you also want the server to enforce the lock, set
|
||||
// Photon:RequireCustomAuth=true on the server (env
|
||||
// Photon__RequireCustomAuth=true).
|
||||
//
|
||||
// The body below is verbatim what lived in Mod.cs's PhotonPatches
|
||||
// class. `FindGameType` is the same helper still in Mod.cs.
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
namespace DorkNet.ClientMod.Attic;
|
||||
|
||||
internal static class AuthValuesInjector
|
||||
{
|
||||
public static void AuthValuesInjector_Prefix(object __instance)
|
||||
{
|
||||
if (!Mod.Cfg.InjectAuthValues) return;
|
||||
try
|
||||
{
|
||||
// Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020
|
||||
// watch never sets them itself, so we set them right before
|
||||
// NetworkingPeer.CallAuthenticate forwards to the wire op. The
|
||||
// userid is RecNet.Core.LocalAccountId, the LoginLock comes
|
||||
// from PlayerPrefs key LoginLockTokenV2.
|
||||
// RecNet.Core.LocalAccountId is declared `int` in the game
|
||||
// build (dump.cs:573622 → 0x30 backing field). Convert via
|
||||
// Convert.ToInt64 so boxing as int still gives us the long we
|
||||
// need below; a direct (long) cast on boxed int would throw.
|
||||
var coreType = FindGameType("RecNet.Core");
|
||||
var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj);
|
||||
if (accountId <= 0)
|
||||
{
|
||||
Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)");
|
||||
return;
|
||||
}
|
||||
|
||||
var photonNetwork = FindGameType("PhotonNetwork");
|
||||
var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static);
|
||||
var existing = avProp?.GetValue(null);
|
||||
if (existing is not null)
|
||||
{
|
||||
var userIdProp = existing.GetType().GetProperty("UserId");
|
||||
var existingUserId = userIdProp?.GetValue(existing) as string;
|
||||
if (!string.IsNullOrEmpty(existingUserId)) return;
|
||||
}
|
||||
|
||||
var token = GetCurrentLockToken();
|
||||
|
||||
// Build a new AuthenticationValues. The type lives in the
|
||||
// ExitGames.Client.Photon.LoadBalancing namespace (game-side).
|
||||
// CustomAuthenticationType.Custom = 0 in this build.
|
||||
var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues");
|
||||
if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; }
|
||||
var av = Activator.CreateInstance(authValuesType);
|
||||
var authTypeProp = authValuesType.GetProperty("AuthType");
|
||||
if (authTypeProp is not null)
|
||||
{
|
||||
var customAuthValue = authTypeProp.PropertyType.IsEnum
|
||||
? Enum.ToObject(authTypeProp.PropertyType, 0)
|
||||
: Convert.ChangeType(0, authTypeProp.PropertyType);
|
||||
authTypeProp.SetValue(av, customAuthValue);
|
||||
}
|
||||
authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString());
|
||||
var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) });
|
||||
addParam?.Invoke(av, new object[] { "userid", accountId.ToString() });
|
||||
addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty });
|
||||
|
||||
avProp?.SetValue(null, av);
|
||||
Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "<missing>" : "<set>")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static string? GetCurrentLockToken()
|
||||
{
|
||||
try
|
||||
{
|
||||
var playerPrefs = FindGameType("UnityEngine.PlayerPrefs");
|
||||
var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) });
|
||||
var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty });
|
||||
if (string.IsNullOrEmpty(raw)) return null;
|
||||
const string key = "\"LockToken\":\"";
|
||||
var ix = raw.IndexOf(key, StringComparison.Ordinal);
|
||||
if (ix < 0) return null;
|
||||
ix += key.Length;
|
||||
var end = raw.IndexOf('"', ix);
|
||||
return end < 0 ? null : raw[ix..end];
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// FindGameType is already defined inside Mod.cs's PhotonPatches
|
||||
// class (private static) — when re-instating, either drop this
|
||||
// private copy and inline the lookup, or move the helper to a
|
||||
// shared internal class. The current shape preserves the original
|
||||
// behaviour for an isolated paste-back.
|
||||
private static Type? FindGameType(string name)
|
||||
{
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return t;
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return prefixed;
|
||||
var dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
|
||||
if (nsPrefixed is not null) return nsPrefixed;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
{
|
||||
"ServerHost": "{HOST}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||
"EnableDebugConsole": false,
|
||||
"DebugConsoleToggleKey": "BackQuote",
|
||||
"DevCommands": [],
|
||||
"QuestMaxTeamSize": 0,
|
||||
"QuestMaxTeamSizeRooms": [
|
||||
"CrimsonCauldron",
|
||||
"Crescendo",
|
||||
"GoldenTrophy",
|
||||
"IsleOfLostSkulls",
|
||||
"TheRiseofJumbotron"
|
||||
]
|
||||
"SingleOriginBaseUrl": "{SINGLE_ORIGIN_BASE_URL}",
|
||||
"PhotonAppId": "{PHOTON_APPID}",
|
||||
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
|
||||
"PhotonCloudRegion": "{PHOTON_REGION}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,309 +0,0 @@
|
||||
namespace DorkNet.Contracts;
|
||||
|
||||
public static class DorkNetRouteOwnership
|
||||
{
|
||||
private const string AdminHost = "admin";
|
||||
|
||||
private static readonly DorkNetRouteGroup[] ServiceRouteGroups =
|
||||
[
|
||||
new(
|
||||
ServiceNames.Identity,
|
||||
["auth", "accounts"],
|
||||
[
|
||||
"/.well-known",
|
||||
"/account",
|
||||
"/api/account",
|
||||
"/api/auth",
|
||||
"/api/createaccount",
|
||||
"/api/platformlogin",
|
||||
"/api/playerreputation",
|
||||
"/api/players",
|
||||
"/api/relationships",
|
||||
"/api/settings",
|
||||
"/cachedlogin",
|
||||
"/connect",
|
||||
"/eac",
|
||||
"/parentalcontrol",
|
||||
"/photon",
|
||||
"/profileimage",
|
||||
"/settings",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Rooms,
|
||||
["rooms", "match", "discovery", "lists", "roomcomments", "roomieintegrations", "studio"],
|
||||
[
|
||||
"/api/activities",
|
||||
"/api/curatedroomplaylists",
|
||||
"/api/gameconfigs",
|
||||
"/api/gameconfigurations",
|
||||
"/api/gamesessions",
|
||||
"/api/leaderboard",
|
||||
"/api/playerelo",
|
||||
"/api/playlists",
|
||||
"/api/roomkeys",
|
||||
"/api/rooms",
|
||||
"/api/royale",
|
||||
"/discover",
|
||||
"/featuredrooms",
|
||||
"/goto",
|
||||
// 2023 clients renamed the whole goto/* matchmaking surface
|
||||
// to matchmake/* (matchmake/room, /instance, /invite,
|
||||
// /chatinvite, …). Same controller as /goto, so it belongs
|
||||
// to the match service; without this it fell through to the
|
||||
// Monolith default owner.
|
||||
"/matchmake",
|
||||
"/hot_rooms",
|
||||
"/hot_roomsandplaylists",
|
||||
"/invite",
|
||||
"/playlists",
|
||||
"/player",
|
||||
"/room",
|
||||
"/roominstance",
|
||||
"/rooms",
|
||||
"/roomsandplaylists",
|
||||
"/roomserver",
|
||||
"/search_rooms",
|
||||
"/search_roomsandplaylists",
|
||||
"/v1/find",
|
||||
"/v1/join",
|
||||
"/v1/leave",
|
||||
"/v1/session",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Notify,
|
||||
["notify", "chat", "platformnotifications"],
|
||||
[
|
||||
"/api/messages",
|
||||
"/api/notification",
|
||||
"/api/offlineinvite",
|
||||
"/hub",
|
||||
"/notify",
|
||||
"/thread",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Content,
|
||||
["cdn", "data", "img", "storage", "strings-cdn", "videos"],
|
||||
[
|
||||
"/api/images",
|
||||
"/api/photos",
|
||||
"/config",
|
||||
"/data",
|
||||
"/img",
|
||||
"/invention",
|
||||
"/upload",
|
||||
"/video",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Social,
|
||||
["clubs"],
|
||||
[
|
||||
"/announcements",
|
||||
"/api/announcement",
|
||||
"/api/clubreporting",
|
||||
"/api/communityboard",
|
||||
"/api/groups",
|
||||
"/api/playercheer",
|
||||
"/api/playerevents",
|
||||
"/api/playersubscriptions",
|
||||
"/club",
|
||||
"/members",
|
||||
"/subscription",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Commerce,
|
||||
["commerce", "econ"],
|
||||
[
|
||||
"/api/avatar",
|
||||
"/api/catalog",
|
||||
"/api/compatibility",
|
||||
"/api/consumables",
|
||||
"/api/equipment",
|
||||
"/api/gamerewards",
|
||||
"/api/inventions",
|
||||
"/api/purchase",
|
||||
"/api/roomconsumables",
|
||||
"/api/roomcurrencies",
|
||||
"/api/storefronts",
|
||||
"/commerce",
|
||||
"/econ",
|
||||
"/purchase",
|
||||
"/purchasecampaign",
|
||||
"/reminder",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Platform,
|
||||
["cards", "cms", "datacollection", "gamelogs", "geo", "leaderboard", "link", "ns", "playersettings", "strings", "thorn"],
|
||||
[
|
||||
"/api/cards",
|
||||
"/api/config",
|
||||
"/api/events",
|
||||
"/api/quickplay",
|
||||
"/api/role",
|
||||
"/api/version",
|
||||
"/api/versioncheck",
|
||||
"/motd",
|
||||
"/pageview",
|
||||
"/ping",
|
||||
"/role",
|
||||
"/strings",
|
||||
"/v1/ping",
|
||||
"/v1/player",
|
||||
"/v1/regions",
|
||||
"/v1/services",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Moderation,
|
||||
["bugreporting", "moderation"],
|
||||
[
|
||||
"/api/admin",
|
||||
"/api/banappeal",
|
||||
"/api/bugreporting",
|
||||
"/api/moderation",
|
||||
"/api/playerreporting",
|
||||
"/api/playersbanned",
|
||||
"/api/sanitize",
|
||||
"/api/testcasemanagement",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Web,
|
||||
["www", AdminHost, "feed"],
|
||||
[
|
||||
"/api/site",
|
||||
],
|
||||
IncludesApexHost: true),
|
||||
];
|
||||
|
||||
public static IReadOnlyList<DorkNetRouteGroup> RouteGroups => ServiceRouteGroups;
|
||||
|
||||
public static bool IsInfrastructurePath(string path)
|
||||
{
|
||||
return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWith("/internal/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string ResolvePublicService(string? host, string path, string apexDomain)
|
||||
{
|
||||
var normalizedPath = NormalizePath(path);
|
||||
var subdomain = Subdomain(host, apexDomain);
|
||||
|
||||
if (string.Equals(subdomain, AdminHost, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ServiceNames.Web;
|
||||
}
|
||||
|
||||
foreach (var group in ServiceRouteGroups.Where(group => group.ServiceName != ServiceNames.Web))
|
||||
{
|
||||
if (MatchesHost(subdomain, group.HostSubdomains))
|
||||
{
|
||||
return group.ServiceName;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var group in ServiceRouteGroups)
|
||||
{
|
||||
if (StartsWithAny(normalizedPath, group.PathPrefixes))
|
||||
{
|
||||
return group.ServiceName;
|
||||
}
|
||||
}
|
||||
|
||||
var web = ServiceRouteGroups.First(group => group.ServiceName == ServiceNames.Web);
|
||||
if ((subdomain is null && web.IncludesApexHost) || MatchesHost(subdomain, web.HostSubdomains))
|
||||
{
|
||||
return ServiceNames.Web;
|
||||
}
|
||||
|
||||
return ServiceNames.Monolith;
|
||||
}
|
||||
|
||||
public static bool IsOwnedBy(string serviceName, string? host, string path, string apexDomain)
|
||||
{
|
||||
if (IsInfrastructurePath(path))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
ResolvePublicService(host, path, apexDomain),
|
||||
serviceName,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string? Subdomain(string? host, string apexDomain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedHost = host.Split(':', 2)[0].Trim().ToLowerInvariant();
|
||||
var normalizedApex = apexDomain.Split(':', 2)[0].Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalizedHost)
|
||||
|| string.IsNullOrWhiteSpace(normalizedApex)
|
||||
|| string.Equals(normalizedHost, normalizedApex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var suffix = "." + normalizedApex;
|
||||
if (normalizedHost.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalizedHost[..^suffix.Length];
|
||||
}
|
||||
|
||||
var dot = normalizedHost.IndexOf('.');
|
||||
return dot > 0 ? normalizedHost[..dot] : null;
|
||||
}
|
||||
|
||||
private static bool StartsWithAny(string path, IEnumerable<string> prefixes)
|
||||
{
|
||||
foreach (var prefix in prefixes)
|
||||
{
|
||||
if (StartsWithPathSegment(path, prefix))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool StartsWithPathSegment(string path, string prefix)
|
||||
{
|
||||
var normalizedPath = NormalizePath(path);
|
||||
var normalizedPrefix = NormalizePath(prefix).TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(normalizedPrefix))
|
||||
{
|
||||
normalizedPrefix = "/";
|
||||
}
|
||||
|
||||
return normalizedPath.Equals(normalizedPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedPath.StartsWith(normalizedPrefix + "/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool MatchesHost(string? subdomain, IEnumerable<string> candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subdomain))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return candidates.Contains(subdomain, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
return path[0] == '/' ? path : "/" + path;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DorkNetRouteGroup(
|
||||
string ServiceName,
|
||||
string[] HostSubdomains,
|
||||
string[] PathPrefixes,
|
||||
bool IncludesApexHost = false);
|
||||
@@ -1,38 +0,0 @@
|
||||
namespace DorkNet.Contracts;
|
||||
|
||||
public sealed class DorkNetServiceMapOptions
|
||||
{
|
||||
public string Identity { get; set; } = "http://localhost:8081";
|
||||
public string Rooms { get; set; } = "http://localhost:8082";
|
||||
public string Notify { get; set; } = "http://localhost:8083";
|
||||
public string Content { get; set; } = "http://localhost:8084";
|
||||
public string Social { get; set; } = "http://localhost:8085";
|
||||
public string Commerce { get; set; } = "http://localhost:8086";
|
||||
public string Platform { get; set; } = "http://localhost:8087";
|
||||
public string Moderation { get; set; } = "http://localhost:8088";
|
||||
public string Web { get; set; } = "http://localhost:8089";
|
||||
public string Monolith { get; set; } = "http://localhost:8090";
|
||||
|
||||
public IReadOnlyList<DorkNetServiceEndpoint> Endpoints()
|
||||
{
|
||||
DorkNetServiceEndpoint[] endpoints =
|
||||
[
|
||||
new(ServiceNames.Identity, Identity),
|
||||
new(ServiceNames.Rooms, Rooms),
|
||||
new(ServiceNames.Notify, Notify),
|
||||
new(ServiceNames.Content, Content),
|
||||
new(ServiceNames.Social, Social),
|
||||
new(ServiceNames.Commerce, Commerce),
|
||||
new(ServiceNames.Platform, Platform),
|
||||
new(ServiceNames.Moderation, Moderation),
|
||||
new(ServiceNames.Web, Web),
|
||||
new(ServiceNames.Monolith, Monolith),
|
||||
];
|
||||
|
||||
return endpoints
|
||||
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint.BaseUrl))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DorkNetServiceEndpoint(string Name, string BaseUrl);
|
||||
@@ -1,19 +0,0 @@
|
||||
namespace DorkNet.Contracts;
|
||||
|
||||
public sealed record ServiceHealthResponse(
|
||||
string Service,
|
||||
string Status,
|
||||
DateTimeOffset CheckedAtUtc);
|
||||
|
||||
public sealed record ServiceCapabilityResponse(
|
||||
string Service,
|
||||
string[] Owns,
|
||||
string[] PlannedPublicRoutes);
|
||||
|
||||
public sealed record ServiceProbeResponse(
|
||||
string Service,
|
||||
string BaseUrl,
|
||||
string Status,
|
||||
int? StatusCode,
|
||||
string? Error,
|
||||
DateTimeOffset CheckedAtUtc);
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace DorkNet.Contracts;
|
||||
|
||||
public static class ServiceNames
|
||||
{
|
||||
public const string Gateway = "gateway";
|
||||
public const string Identity = "identity";
|
||||
public const string Rooms = "rooms";
|
||||
public const string Notify = "notify";
|
||||
public const string Content = "content";
|
||||
public const string Social = "social";
|
||||
public const string Commerce = "commerce";
|
||||
public const string Platform = "platform";
|
||||
public const string Moderation = "moderation";
|
||||
public const string Web = "web";
|
||||
public const string Monolith = "monolith";
|
||||
}
|
||||
@@ -1,3751 +0,0 @@
|
||||
// DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
|
||||
//
|
||||
// The shipping ClientMod carries ONLY the patches needed to connect and
|
||||
// survive anti-cheat. All of the verbose diagnostic/tracing code lives
|
||||
// here: HTTP header/request tracing, join/quit/studio/room-load traces,
|
||||
// the process-level exception sink, auth-token tracing, the registration
|
||||
// dialog trace, and the in-game DebugConsole enable/toggle path. Drop
|
||||
// DorkNet.DebugMod.dll into the Mods folder next to DorkNet.ClientMod.dll
|
||||
// to turn all of it on; remove it to ship a quiet, minimal client.
|
||||
//
|
||||
// It is a fully self-contained MelonMod (its own Harmony instance + its
|
||||
// own copy of the reflection/patch helpers) so it neither needs nor
|
||||
// couples to ClientMod at load time. Debug knobs are read from the same
|
||||
// <MelonLoader>\UserData\dorknet-clientmod.json the ClientMod uses.
|
||||
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.DebugMod.DebugMod), "DorkNet DebugMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.DebugMod;
|
||||
|
||||
public class DebugMod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||
internal static DebugMod? Instance;
|
||||
internal static string? DiagnosticsLogPath;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _httpDiagnosticsRegistered;
|
||||
private bool _joinTracePatchesRegistered;
|
||||
private bool _quitTraceCoreRegistered;
|
||||
private bool _quitTraceGameComplete;
|
||||
private int _quitTraceRetryFrame;
|
||||
private bool _debugConsolePatchesComplete;
|
||||
private int _debugConsoleRetryFrame;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private bool _processExceptionHandlersRegistered;
|
||||
private bool _firstUpdateLogged;
|
||||
private bool _devCommandsDumped;
|
||||
private int _devCommandsDumpFrame;
|
||||
private int _devCommandsDumpTries;
|
||||
|
||||
public static class Cfg
|
||||
{
|
||||
// Log-only trace for the stuck first-dorm "Change Username?" dialog.
|
||||
public static bool TraceRegistrationDialog = true;
|
||||
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
|
||||
// (normally dev-gated) and silence CheatManager's tamper detectors so
|
||||
// movement/time commands don't drop you to the dorm. Default OFF. The
|
||||
// toggle key is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
public static string[] DevCommands = Array.Empty<string>();
|
||||
// Read-only one-shot: dump the full runtime command table to
|
||||
// UserData/dorknet-devcommands.txt. Default OFF.
|
||||
public static bool DumpDevCommands = false;
|
||||
}
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet DebugMod loading ===");
|
||||
LoadConfig();
|
||||
}
|
||||
|
||||
public override void OnLateInitializeMelon()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostic patches ===");
|
||||
RegisterProcessExceptionHandlers();
|
||||
RegisterHttpDiagnostics();
|
||||
RegisterQuitTracePatches(logMisses: true);
|
||||
RegisterStudioTracePatches();
|
||||
RegisterJoinTracePatches();
|
||||
// Auth-token setter trace (LBNJFPOLCDL.JHLKGBGJKKK(access, refresh, key)).
|
||||
// The shipping ClientMod used to register this unconditionally; it now
|
||||
// lives here with the rest of the diagnostics.
|
||||
TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK",
|
||||
args: new[] { typeof(string), typeof(string), typeof(string) },
|
||||
prefix: nameof(AuthPatches.CaptureTokenArgs_Prefix));
|
||||
// Deep game-side diagnostics: CheatManager tamper-detection logging,
|
||||
// the Unity Debug.LogError/LogException sink, Photon connect/join
|
||||
// tracing, and the room-load MoveNext state dumps. Unwired in the
|
||||
// shipping client (kept out of the minimal footprint); this is their
|
||||
// home now that diagnostics are opt-in.
|
||||
RegisterDiagnostics();
|
||||
if (Cfg.EnableDebugConsole)
|
||||
RegisterDebugConsolePatches(logMisses: true);
|
||||
Log.Msg("=== Diagnostic patches registered ===");
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (!_firstUpdateLogged)
|
||||
{
|
||||
_firstUpdateLogged = true;
|
||||
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
|
||||
}
|
||||
|
||||
if (!_quitTraceGameComplete && ++_quitTraceRetryFrame >= 300)
|
||||
{
|
||||
_quitTraceRetryFrame = 0;
|
||||
RegisterQuitTracePatches(logMisses: false);
|
||||
}
|
||||
|
||||
if (Cfg.EnableDebugConsole && !_debugConsolePatchesComplete && ++_debugConsoleRetryFrame >= 300)
|
||||
{
|
||||
_debugConsoleRetryFrame = 0;
|
||||
RegisterDebugConsolePatches(logMisses: false);
|
||||
}
|
||||
|
||||
if (Cfg.EnableDebugConsole)
|
||||
DebugConsolePatches.PollToggleKey();
|
||||
|
||||
// Retry the read-only command dump every ~5s (the config singleton only
|
||||
// exists after the ~26s load + dorm entry). Stops as soon as it finds a
|
||||
// populated runtime table, or after ~2.5 min of attempts.
|
||||
if (Cfg.DumpDevCommands && !_devCommandsDumped && ++_devCommandsDumpFrame >= 300)
|
||||
{
|
||||
_devCommandsDumpFrame = 0;
|
||||
if (DebugConsolePatches.DumpCommandDiagnostics() || ++_devCommandsDumpTries >= 30)
|
||||
_devCommandsDumped = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
private static void LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userData = ResolveUserDataDirectory();
|
||||
Directory.CreateDirectory(userData);
|
||||
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
|
||||
|
||||
var path = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Msg($"[config] no file at {path}; using debug defaults");
|
||||
return;
|
||||
}
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (TryGetConfigValue(r, "TraceRegistrationDialog", out var v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (TryGetConfigValue(r, "DumpDevCommands", out v)) Cfg.DumpDevCommands = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var commands = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||
commands.Add(e.GetString()!);
|
||||
Cfg.DevCommands = commands.ToArray();
|
||||
}
|
||||
Log.Msg($"[config] debug: debugConsole={Cfg.EnableDebugConsole}, " +
|
||||
$"dumpDevCommands={Cfg.DumpDevCommands}, devCommands={Cfg.DevCommands.Length}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
foreach (var holder in new[] { typeof(HttpTracePatches), typeof(AuthPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(MakerPenGiftPreviewPatches) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
}
|
||||
throw new MissingMethodException($"patch method '{name}' not found on any holder");
|
||||
}
|
||||
private bool TryPatch(string label, Type target, string methodName,
|
||||
Type[]? args, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = methodName == "ctor"
|
||||
? (MethodBase)target.GetConstructor(args ?? Type.EmptyTypes)!
|
||||
: (args is null ? AccessTools.Method(target, methodName)
|
||||
: AccessTools.Method(target, methodName, args));
|
||||
if (method is null)
|
||||
{
|
||||
// `logMiss` is throttled by the retry-loop caller — once per
|
||||
// 5 seconds in OnUpdate, every time in the one-shot initial
|
||||
// RegisterDiagnostics. Without the gate the per-second retry
|
||||
// loop would spam `[patch-miss]` lines forever for any method
|
||||
// whose name drifted in a newer game build.
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}");
|
||||
return false;
|
||||
}
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {label}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[patch-fail] {label}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
private bool TryPatchByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var label = FormatPatchLabel(typeName, methodName, args);
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
||||
}
|
||||
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var label = FormatPatchLabel(typeName, methodName, args);
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
if (!TryPatchByName(typeName, methodName, args, prefix, postfix, logMiss))
|
||||
return false;
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
internal static Type? ResolveType(string name)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||
return cached;
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return CacheResolvedType(name, t);
|
||||
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
|
||||
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return CacheResolvedType(name, prefixed);
|
||||
// For namespaced types like "RecNet.Core", also try with
|
||||
// the Il2Cpp prefix on the namespace (i.e. "Il2CppRecNet.Core").
|
||||
var dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
|
||||
if (nsPrefixed is not null) return CacheResolvedType(name, nsPrefixed);
|
||||
}
|
||||
}
|
||||
catch { /* unloaded / dynamic asm — skip */ }
|
||||
}
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var t in GetLoadableTypes(asm))
|
||||
{
|
||||
if (MatchesRequestedType(t, name))
|
||||
return CacheResolvedType(name, t);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static Type CacheResolvedType(string name, Type type)
|
||||
{
|
||||
ResolvedTypeCache[name] = type;
|
||||
return type;
|
||||
}
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
|
||||
{
|
||||
try
|
||||
{
|
||||
return asm.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
var types = new List<Type>();
|
||||
foreach (var t in ex.Types)
|
||||
{
|
||||
if (t is not null) types.Add(t);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
}
|
||||
private static bool MatchesRequestedType(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
var dottedPrefixed = "Il2Cpp." + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
|
||||
if (type.Name == requested || type.Name == prefixed) return true;
|
||||
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
||||
{
|
||||
if (args is null) return $"{typeName}.{methodName}";
|
||||
var names = new List<string>();
|
||||
foreach (var arg in args)
|
||||
names.Add(arg.Name);
|
||||
return $"{typeName}.{methodName}({string.Join(",", names)})";
|
||||
}
|
||||
private static string FormatParameterTypes(ParameterInfo[] parameters)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var parameter in parameters)
|
||||
names.Add(parameter.ParameterType.Name);
|
||||
return string.Join(",", names);
|
||||
}
|
||||
private static bool TypeNameContains(Type type, string value)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains(value, StringComparison.Ordinal);
|
||||
}
|
||||
private static bool MethodNameContains(MethodInfo method, string value)
|
||||
=> method.Name.Contains(value, StringComparison.Ordinal);
|
||||
private static string ResolveUserDataDirectory()
|
||||
{
|
||||
foreach (var typeName in new[]
|
||||
{
|
||||
"MelonLoader.MelonEnvironment, MelonLoader",
|
||||
"MelonLoader.MelonUtils, MelonLoader",
|
||||
})
|
||||
{
|
||||
var type = Type.GetType(typeName);
|
||||
var prop = type?.GetProperty("UserDataDirectory", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop?.GetValue(null) is string value && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
|
||||
return Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
}
|
||||
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
private void RegisterJoinTracePatches()
|
||||
{
|
||||
if (_joinTracePatchesRegistered) return;
|
||||
Log.Msg("=== Registering room-join trace ===");
|
||||
|
||||
// Watch "Dorm Room" button. Void, no params, unique name —
|
||||
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
|
||||
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
|
||||
|
||||
// Every SessionManager.RunJoinRoom overload (string-name entry +
|
||||
// the RoomInstance inner legs). Logs the method + its arguments.
|
||||
PatchAllOverloads("SessionManager", "RunJoinRoom",
|
||||
nameof(JoinPatches.RunJoinRoom_Prefix));
|
||||
|
||||
// The /goto/room/{name|id} promise builders — both the string
|
||||
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
|
||||
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
|
||||
nameof(JoinPatches.Goto_Prefix));
|
||||
|
||||
// The RunJoinRoom promise-chain callbacks compiled onto the
|
||||
// SessionManager <>c display class — most importantly the reject
|
||||
// handler b__165_1(string error) that raises the happyfox toast.
|
||||
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
|
||||
// marker "b__" so we hit the callbacks but not the RunJoinRoom
|
||||
// state-machine MoveNext.
|
||||
PatchNestedLambdas("SessionManager", "RunJoinRoom",
|
||||
nameof(JoinPatches.JoinCallback_Prefix));
|
||||
|
||||
// Additionally capture the REJECT REASON string on b__165_1 (the
|
||||
// happyfox-toast handler). Matches only that one lambda; it ends
|
||||
// up with two prefixes (flow + error string), which is harmless.
|
||||
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
|
||||
nameof(JoinPatches.JoinError_Prefix));
|
||||
|
||||
_joinTracePatchesRegistered = true;
|
||||
Log.Msg("=== Room-join trace registered ===");
|
||||
}
|
||||
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): type not found");
|
||||
return false;
|
||||
}
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != methodName) continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
|
||||
}
|
||||
}
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
|
||||
return false;
|
||||
}
|
||||
Log.Msg($"[patch-ok] {typeName}.{methodName}(*) x{patched}");
|
||||
return true;
|
||||
}
|
||||
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
|
||||
return false;
|
||||
}
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
var patched = 0;
|
||||
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
|
||||
{
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(nested))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (method.Name.IndexOf("b__", StringComparison.Ordinal) < 0) continue;
|
||||
if (method.Name.IndexOf(nameContains, StringComparison.Ordinal) < 0) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] {typeName}+{nested.Name}.{method.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}+{nested.Name}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patched == 0 && logMiss)
|
||||
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
|
||||
return patched > 0;
|
||||
}
|
||||
private void RegisterHttpDiagnostics()
|
||||
{
|
||||
if (_httpDiagnosticsRegistered) return;
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatchBndRequestBuilderDiagnostics();
|
||||
complete &= TryPatchDiagnosticAllOverloads("BestHTTP.HTTPRequest", "CallCallback",
|
||||
postfix: nameof(HttpTracePatches.CallCallback_Postfix),
|
||||
finalizer: nameof(HttpTracePatches.CallCallback_Finalizer));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "AddHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "SetHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
|
||||
_httpDiagnosticsRegistered = complete;
|
||||
}
|
||||
private bool TryPatchBndRequestBuilderDiagnostics()
|
||||
{
|
||||
const string label = "BNDIAONDFFF.request-builder";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var type = ResolveType("BNDIAONDFFF");
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning("[patch-miss] BNDIAONDFFF request-builder: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var prefix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix)));
|
||||
var postfix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix)));
|
||||
var patched = 0;
|
||||
|
||||
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
var parameters = ctor.GetParameters();
|
||||
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(ctor, prefix: prefix, postfix: postfix);
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] BNDIAONDFFF..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != "FILJELOLBKK") continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: prefix, postfix: postfix);
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] BNDIAONDFFF.FILJELOLBKK({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] BNDIAONDFFF request-builder overloads");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
Log.Msg($"[patch-ok] BNDIAONDFFF request-builder x{patched}");
|
||||
return true;
|
||||
}
|
||||
private void RegisterQuitTracePatches(bool logMisses)
|
||||
{
|
||||
if (!_quitTraceCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(QuitTracePatches.ApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(QuitTracePatches.ApplicationQuitInt_Prefix),
|
||||
logMiss: logMisses);
|
||||
_quitTraceCoreRegistered = true;
|
||||
}
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM",
|
||||
prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "DGHJGMBNHCL",
|
||||
prefix: nameof(QuitTracePatches.CreateExitTask_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
_quitTraceGameComplete = complete;
|
||||
}
|
||||
private void RegisterDiagnostics()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostics ===");
|
||||
if (!_diagnosticCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
|
||||
_diagnosticCoreRegistered = true;
|
||||
}
|
||||
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
|
||||
Log.Msg("=== Diagnostics registered ===");
|
||||
}
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
// CheatManager fires these when its anti-cheat heuristics trip —
|
||||
// logging them surfaces "client thinks it's been tampered with",
|
||||
// a common cause of silent dorm-drops / kicks on a modded client.
|
||||
// Names verified in CheatManager.txt for 2020.12.18; the older
|
||||
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
|
||||
// there's no DeveloperFlag/StreamingAsset callback nor a
|
||||
// BootSequence.RegisterError sink in this build — those legacy
|
||||
// hooks were removed (they only ever logged [patch-miss] forever).
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"OnTimeCheatDetected",
|
||||
"OnObscuredTypeCheatDetected",
|
||||
"OnHeightCheatDetected",
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
})
|
||||
{
|
||||
TryPatchDiagnosticByName("CheatManager", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
RegisterDeepRoomDiagnostics(logMisses);
|
||||
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
|
||||
// pure-logging diagnostics that fired during Unity's very first
|
||||
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
|
||||
// methods) MonoMod's lazy CompileMethodHook on the trampoline
|
||||
// crashes the CLR with 0x80131506 the instant Awake JITs. The
|
||||
// hooks gave us no behavioral leverage — only a `[call] …` log
|
||||
// line — so they're disabled. If anyone needs Steam-side visibility
|
||||
// later, route it through the game's own log lines instead.
|
||||
return true;
|
||||
}
|
||||
private void RegisterDebugConsolePatches(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("RecRoom.Debugging.DebugConsole",
|
||||
"Awake",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.Awake_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("RecRoom.Debugging.DebugConsole",
|
||||
"InputField_OnEndEdit",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.InputFieldOnEndEdit_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
// Dev commands that change movement/time trip retail anti-cheat
|
||||
// callbacks. Suppress the three callbacks present in the 2023.03.21
|
||||
// dump; keep the legacy height detector as a best-effort optional
|
||||
// patch for adjacent builds without keeping this retry loop alive.
|
||||
complete &= TryPatchDiagnosticByName("CheatManager",
|
||||
"OnTimeCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("CheatManager",
|
||||
"OnObscuredTypeCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("CheatManager",
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: logMisses);
|
||||
TryPatchDiagnosticByName("CheatManager",
|
||||
"OnHeightCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: false);
|
||||
|
||||
_debugConsolePatchesComplete = complete;
|
||||
if (complete)
|
||||
Log.Msg("[debugconsole] restoration patches armed");
|
||||
}
|
||||
private bool TryPatchDiagnosticAllOverloads(string typeName, string methodName,
|
||||
string? prefix = null, string? postfix = null,
|
||||
string? finalizer = null, bool logMiss = true)
|
||||
{
|
||||
var label = $"{typeName}.{methodName}(*)";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != methodName) continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)),
|
||||
finalizer: finalizer is null ? null : new HarmonyMethod(GetPatchMethod(finalizer)));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: no overloads matched");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
Log.Msg($"[patch-ok] {label} x{patched}");
|
||||
return true;
|
||||
}
|
||||
private bool RegisterDeepRoomDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
|
||||
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogError",
|
||||
args: new[] { typeof(object) },
|
||||
prefix: nameof(RoomLoadDiagnostics.LogError_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogException",
|
||||
args: new[] { typeof(Exception) },
|
||||
prefix: nameof(RoomLoadDiagnostics.LogException_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"ConnectUsingSettings",
|
||||
"ConnectToRegion",
|
||||
"JoinRoom",
|
||||
"JoinOrCreateRoom",
|
||||
"CreateRoom",
|
||||
"LeaveRoom",
|
||||
"Disconnect",
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticAllOverloads("PhotonNetwork", method,
|
||||
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
foreach (var item in new (string Type, string Method)[]
|
||||
{
|
||||
("CMMIJKCDKPL", "CDNPLAGEDBK"),
|
||||
("KGHEDLNPANK", "CDNPLAGEDBK"),
|
||||
("HMPNINFEDFB", "IJIOJAIBPKE"),
|
||||
("PhotonNetwork", "Disconnect"),
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticAllOverloads(item.Type, item.Method,
|
||||
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= PatchKnownRoomLoadMoveNext(logMisses);
|
||||
return true;
|
||||
}
|
||||
private void RegisterStudioTracePatches()
|
||||
{
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "RegisterPrefabs",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioRegisterPrefabs_Postfix));
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "TryGetPrefab",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioTryGetPrefab_Postfix));
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "IsKnownPrefab",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioIsKnownPrefab_Postfix));
|
||||
TryPatchByName("NLDBPDCNNCF", "FKLDMPMFLBD",
|
||||
prefix: nameof(RoomLoadDiagnostics.StudioUnityAssetFetch_Prefix));
|
||||
}
|
||||
private bool PatchKnownRoomLoadMoveNext(bool logMiss)
|
||||
{
|
||||
const string label = "room-load-state-machines.MoveNext";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var nameNeedles = new[]
|
||||
{
|
||||
"OMJKHJLFOCO", "AFMPFLKGABO",
|
||||
"AOEBBIFGIKI", "FINJCCNPHEP", "FDDFOKNJIFL", "GMNHJNAHAAI",
|
||||
"MHDOLJJPEAD", "LFGLDJCMJDN", "PFDIKJHMBDI", "ICAGCHKPIOM",
|
||||
"JEBACILNLJI", "DINBAFNHLPJ", "HLHPNABABME", "PAPLNIPKAMG",
|
||||
"IBEOONPEELF", "JHPHMNGOFLB", "NLDBPDCNNCF", "LONOBOBHMJL",
|
||||
"BNDIAONDFFF", "HOONLPFFADG", "PHODPEHLHGM",
|
||||
};
|
||||
|
||||
var patched = 0;
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
var match = false;
|
||||
foreach (var needle in nameNeedles)
|
||||
{
|
||||
if (fullName.IndexOf(needle, StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) continue;
|
||||
|
||||
IEnumerable<MethodInfo> methods;
|
||||
try { methods = AccessTools.GetDeclaredMethods(type); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
if (method.Name != "MoveNext") continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
finalizer: new HarmonyMethod(GetPatchMethod(nameof(RoomLoadDiagnostics.MoveNext_Finalizer))));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] room-load MoveNext {type.FullName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] room-load MoveNext {type.FullName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning("[patch-miss] room-load state-machine MoveNext hooks");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
private void RegisterProcessExceptionHandlers()
|
||||
{
|
||||
if (_processExceptionHandlersRegistered) return;
|
||||
_processExceptionHandlersRegistered = true;
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
DiagnosticPatches.Write($"[process-exception] unhandled terminating={e.IsTerminating} {RoomLoadDiagnostics.FormatException(ex, e.ExceptionObject)}");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write($"[process-exception] unobserved-task {RoomLoadDiagnostics.FormatException(e.Exception, e.Exception)}");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RoomLoadDiagnostics
|
||||
{
|
||||
private static readonly Dictionary<string, int> Counts = new();
|
||||
private static int _globalCount;
|
||||
|
||||
public static void LogError_Prefix(object? message)
|
||||
{
|
||||
DiagnosticPatches.Write($"[unity-error] LogError {Sanitize(message?.ToString() ?? "<null>", 1600)}");
|
||||
WriteStack("unity-error");
|
||||
}
|
||||
|
||||
public static void LogException_Prefix(Exception exception)
|
||||
{
|
||||
DiagnosticPatches.Write($"[unity-exception] {FormatException(exception, exception)}");
|
||||
WriteStack("unity-exception");
|
||||
}
|
||||
|
||||
public static void NamedCall_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("call:" + name, 80)) return;
|
||||
|
||||
DiagnosticPatches.Write($"[runtime-call] ENTER {name} [{Signature(__originalMethod)}]");
|
||||
if (IsImportantCall(name))
|
||||
WriteStack("runtime-call");
|
||||
}
|
||||
|
||||
public static Exception? NamedCall_Finalizer(Exception? __exception, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write($"[runtime-call] EXCEPTION {FullMethodName(__originalMethod)} {FormatException(__exception, __exception)}");
|
||||
WriteStack("runtime-call-exception");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void MoveNext_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("enter:" + name, 120)) return;
|
||||
DiagnosticPatches.Write($"[room-load] ENTER {name} state={DumpInstanceFields(__instance, 28)}");
|
||||
}
|
||||
|
||||
public static void MoveNext_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("exit:" + name, 120)) return;
|
||||
DiagnosticPatches.Write($"[room-load] EXIT {name} state={DumpInstanceFields(__instance, 28)}");
|
||||
}
|
||||
|
||||
public static Exception? MoveNext_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[room-load] EXCEPTION {FullMethodName(__originalMethod)} " +
|
||||
$"{FormatException(__exception, __exception)} state={DumpInstanceFields(__instance, 40)}");
|
||||
WriteStack("room-load-exception");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
// ── Studio baked-asset load trace ──────────────────────────────────
|
||||
// Pinpoints where the baked-Studio-scene load chain breaks. The room
|
||||
// joins but renders no custom geometry and logs "Expected a prefab,
|
||||
// but found none"; the server serves everything but the client never
|
||||
// requests a bundle. These probes answer: (1) does the client ever
|
||||
// call the RecNet "get unity asset" API (FKLDMPMFLBD); (2) does it
|
||||
// ever register baked prefabs; (3) which prefab GUIDs does it look up
|
||||
// and miss.
|
||||
|
||||
/// <summary>RecNet unity-asset getter
|
||||
/// <c>NLDBPDCNNCF.FKLDMPMFLBD(string unityAssetId, byte target, int version, CancellationToken)</c>
|
||||
/// — the call that builds <c>unity_assets/{id}/{target}/{version}</c>. If
|
||||
/// this never fires, the client decided not to fetch the bundle at all
|
||||
/// (trigger missing upstream); if it fires, the URL/host/response is the
|
||||
/// problem.</summary>
|
||||
public static void StudioUnityAssetFetch_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] UNITY-ASSET FETCH id={__0?.ToString() ?? "<null>"} " +
|
||||
$"target={__1?.ToString() ?? "?"} version={__2?.ToString() ?? "?"}");
|
||||
WriteStack("studio-assetfetch");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.RegisterPrefabs</c> — baked
|
||||
/// prefabs being registered after a bundle loads. If this never fires,
|
||||
/// no bundle was loaded.</summary>
|
||||
public static void StudioRegisterPrefabs_Postfix(object? __0)
|
||||
{
|
||||
int count = -1;
|
||||
try
|
||||
{
|
||||
if (__0 is not null)
|
||||
{
|
||||
var p = __0.GetType().GetProperty("Count");
|
||||
if (p?.GetValue(__0) is int c) count = c;
|
||||
}
|
||||
}
|
||||
catch { /* best-effort count */ }
|
||||
DiagnosticPatches.Write($"[studio] RegisterPrefabs count={count}");
|
||||
WriteStack("studio-registerprefabs");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.TryGetPrefab(Guid, out GameObject)</c>
|
||||
/// — the lookup that fails with "Expected a prefab, but found none".
|
||||
/// Logs the GUID + whether it resolved.</summary>
|
||||
public static void StudioTryGetPrefab_Postfix(object? __0, bool __result)
|
||||
{
|
||||
if (!ShouldLog("studio-trygetprefab", 200)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] TryGetPrefab guid={__0?.ToString() ?? "<null>"} found={__result}");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.IsKnownPrefab(Guid)</c>.</summary>
|
||||
public static void StudioIsKnownPrefab_Postfix(object? __0, bool __result)
|
||||
{
|
||||
if (!ShouldLog("studio-isknownprefab", 200)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] IsKnownPrefab guid={__0?.ToString() ?? "<null>"} known={__result}");
|
||||
}
|
||||
|
||||
public static string FormatException(Exception? ex, object? fallback)
|
||||
{
|
||||
if (ex is null) return fallback?.ToString() ?? "<null>";
|
||||
var builder = new StringBuilder();
|
||||
AppendException(builder, ex, 0);
|
||||
return Sanitize(builder.ToString(), 5000);
|
||||
}
|
||||
|
||||
private static void AppendException(StringBuilder builder, Exception ex, int depth)
|
||||
{
|
||||
if (depth > 4) return;
|
||||
if (builder.Length > 0) builder.Append(" | ");
|
||||
builder.Append(ex.GetType().FullName).Append(": ").Append(ex.Message);
|
||||
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
|
||||
builder.Append(" stack=").Append(ex.StackTrace);
|
||||
if (ex is AggregateException aggregate)
|
||||
{
|
||||
foreach (var inner in aggregate.InnerExceptions)
|
||||
AppendException(builder, inner, depth + 1);
|
||||
}
|
||||
else if (ex.InnerException is not null)
|
||||
{
|
||||
AppendException(builder, ex.InnerException, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldLog(string key, int perKeyLimit)
|
||||
{
|
||||
lock (Counts)
|
||||
{
|
||||
_globalCount++;
|
||||
if (_globalCount > 12000)
|
||||
return false;
|
||||
|
||||
Counts.TryGetValue(key, out var count);
|
||||
if (count >= perKeyLimit)
|
||||
return false;
|
||||
|
||||
Counts[key] = count + 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DumpInstanceFields(object? instance, int maxFields)
|
||||
{
|
||||
if (instance is null) return "<null>";
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? value;
|
||||
try { value = field.GetValue(instance); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
var described = DescribeValue(value, 400);
|
||||
if (ShouldExpandField(field, value))
|
||||
described += " " + DumpNestedFields(value, 16);
|
||||
parts.Add($"{field.Name}={described}");
|
||||
}
|
||||
|
||||
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 400)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldExpandField(FieldInfo field, object? value)
|
||||
{
|
||||
if (value is null) return false;
|
||||
if (value is string) return false;
|
||||
|
||||
var valueType = value.GetType();
|
||||
if (valueType.IsPrimitive || valueType.IsEnum || value is decimal)
|
||||
return false;
|
||||
if (value is IEnumerable)
|
||||
return false;
|
||||
|
||||
var fieldName = field.Name ?? string.Empty;
|
||||
var typeName = valueType.FullName ?? valueType.Name;
|
||||
return fieldName.IndexOf("this", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
typeName.IndexOf("OMJKHJLFOCO", StringComparison.Ordinal) >= 0 ||
|
||||
typeName.IndexOf("RoomPermissions", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static string DumpNestedFields(object value, int maxFields)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = value.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? nested;
|
||||
try { nested = field.GetValue(value); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{field.Name}={DescribeValue(nested, 220)}");
|
||||
}
|
||||
|
||||
return "{nested " + string.Join("; ", parts) + "}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"{{nested dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 220)}}}";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeValue(object? value, int limit)
|
||||
{
|
||||
if (value is null) return "null";
|
||||
|
||||
var type = value.GetType();
|
||||
if (value is string text) return "\"" + Sanitize(text, limit) + "\"";
|
||||
if (value is Exception ex) return FormatException(ex, ex);
|
||||
if (type.IsPrimitive || type.IsEnum || value is decimal)
|
||||
return Sanitize(value.ToString() ?? "<null>", limit);
|
||||
if (value is IEnumerable enumerable && value is not string)
|
||||
return DescribeEnumerable(enumerable, type, limit);
|
||||
|
||||
var rendered = value.ToString();
|
||||
if (string.IsNullOrWhiteSpace(rendered) || rendered == type.FullName)
|
||||
return "<" + type.FullName + ">";
|
||||
return "<" + type.FullName + " " + Sanitize(rendered, limit) + ">";
|
||||
}
|
||||
|
||||
private static string DescribeEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var count = 0;
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (count++ >= 6)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
parts.Add(DescribeValue(item, 120));
|
||||
}
|
||||
return "<" + type.FullName + " [" + Sanitize(string.Join(", ", parts), limit) + "]>";
|
||||
}
|
||||
|
||||
private static string Signature(MethodBase method)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
var parts = new List<string>(parameters.Length);
|
||||
foreach (var parameter in parameters)
|
||||
parts.Add(parameter.ParameterType.FullName ?? parameter.ParameterType.Name);
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "<signature unread>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FullMethodName(MethodBase method) =>
|
||||
$"{method.DeclaringType?.FullName ?? "<null>"}.{method.Name}";
|
||||
|
||||
private static bool IsImportantCall(string name) =>
|
||||
name.IndexOf("Disconnect", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("Join", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("Photon", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("CDNPLAGEDBK", StringComparison.Ordinal) >= 0 ||
|
||||
name.IndexOf("IJIOJAIBPKE", StringComparison.Ordinal) >= 0;
|
||||
|
||||
private static void WriteStack(string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write($"[{tag}-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[{tag}-stack] failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string value, int limit)
|
||||
{
|
||||
var redacted = Regex.Replace(value, @"(?i)(Bearer\s+)[-._~+/=A-Za-z0-9]+", "$1<redacted>");
|
||||
redacted = Regex.Replace(redacted, @"(?i)(access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)", "$1=<redacted>");
|
||||
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
return redacted.Length <= limit ? redacted : redacted[..limit] + "...<truncated>";
|
||||
}
|
||||
}
|
||||
internal static class DebugConsolePatches
|
||||
{
|
||||
private static bool _consoleVisible;
|
||||
private static bool _pollErrorLogged;
|
||||
private static int _probeFrame;
|
||||
private static bool _probeDone;
|
||||
|
||||
private static bool _inputResolved;
|
||||
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
|
||||
private static object? _toggleKeyValue; // boxed KeyCode enum value
|
||||
|
||||
private static bool _consoleResolved;
|
||||
private static Type? _consoleType;
|
||||
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||
private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string)
|
||||
private static List<ConfiguredCommand>? _commandCache;
|
||||
|
||||
// Harmony prefix returning false → skips the original CheatManager
|
||||
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
public static bool Awake_Prefix()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool InputFieldOnEndEdit_Prefix(object __instance)
|
||||
{
|
||||
var command = ReadConsoleInput(__instance);
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return true;
|
||||
|
||||
ExecuteCommand(command, __instance);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string FormatConfiguredCommandsForLog()
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
if (commands.Count == 0) return "<none>";
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var command in commands)
|
||||
parts.Add($"{command.Label}={command.Command}");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
// Polled every frame from DebugMod.OnUpdate while EnableDebugConsole is set.
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_inputResolved) ResolveInput();
|
||||
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
|
||||
// console object actually exists, so we learn that even if the
|
||||
// hotkey never fires.
|
||||
if (!_probeDone && _probeFrame++ > 180)
|
||||
{
|
||||
_probeDone = true;
|
||||
ProbeConsole();
|
||||
}
|
||||
if (_getKeyDown is null || _toggleKeyValue is null) return;
|
||||
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
|
||||
{
|
||||
DebugMod.Log.Msg($"[debugconsole] toggle key '{DebugMod.Cfg.DebugConsoleToggleKey}' detected");
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_pollErrorLogged) { _pollErrorLogged = true; DebugMod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic: reports whether a live DebugConsole component exists and
|
||||
// whether the show/hide method resolved — without needing a keypress.
|
||||
private static void ProbeConsole()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) { DebugMod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||
try
|
||||
{
|
||||
var active = FindConsoleInstance();
|
||||
DebugMod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||
}
|
||||
catch (Exception ex) { DebugMod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public static void ExecuteCommand(string command) => ExecuteCommand(command, consoleInstance: null);
|
||||
|
||||
private static void ExecuteCommand(string command, object? consoleInstance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return;
|
||||
|
||||
var output = RunCommand(command);
|
||||
if (consoleInstance is null)
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
consoleInstance = FindConsoleInstance();
|
||||
}
|
||||
if (consoleInstance is not null)
|
||||
WriteConsoleOutput(consoleInstance, output);
|
||||
|
||||
var parts = SplitCommandLine(command);
|
||||
var verb = parts.Count > 0 ? parts[0] : "<empty>";
|
||||
DebugMod.Log.Msg($"[debugconsole] command '{CleanLabel(verb)}': {FirstLine(output)}");
|
||||
}
|
||||
|
||||
private static string RunCommand(string commandLine)
|
||||
{
|
||||
var tokens = SplitCommandLine(commandLine);
|
||||
if (tokens.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var verb = tokens[0];
|
||||
if (IsHelpCommand(verb))
|
||||
return BuildCommandReferenceText("Debug Console Commands", maxCommands: 120);
|
||||
|
||||
var args = tokens.GetRange(1, tokens.Count - 1);
|
||||
if (!TryFindCommand(verb, out var command))
|
||||
return $"Unknown debug command: {verb}\n\n{BuildCommandReferenceText("Available Debug Console Commands", maxCommands: 80)}";
|
||||
|
||||
if (!command.HasRuntimeTarget)
|
||||
{
|
||||
var expanded = SplitCommandLine(command.Command);
|
||||
if (expanded.Count == 0)
|
||||
return $"Debug command '{verb}' has no backing command.";
|
||||
|
||||
var expandedArgs = expanded.Count > 1 ? expanded.GetRange(1, expanded.Count - 1) : new List<string>();
|
||||
expandedArgs.AddRange(args);
|
||||
if (!TryFindCommand(expanded[0], out command) || !command.HasRuntimeTarget)
|
||||
return $"Debug command '{verb}' points at '{command.Command}', but no runtime command metadata was found.";
|
||||
args = expandedArgs;
|
||||
}
|
||||
|
||||
if (!TryResolveCommandType(command, out var targetType, out var typeError))
|
||||
return typeError;
|
||||
|
||||
if (!TryFindCallableMethod(targetType!, command.MethodName, args, out var method, out var converted, out var methodError))
|
||||
return methodError;
|
||||
|
||||
object? target = null;
|
||||
if (!method!.IsStatic)
|
||||
{
|
||||
target = FindFirstInstance(targetType!);
|
||||
if (target is null)
|
||||
return $"Command '{command.MethodName}' needs a live {targetType!.Name} instance, but none was found.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = method.Invoke(target, converted);
|
||||
var suffix = result is null ? string.Empty : $"\nResult: {FormatResult(result)}";
|
||||
return $"Executed: {command.Label}{suffix}";
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
var inner = ex.InnerException ?? ex;
|
||||
return $"Command '{command.MethodName}' failed: {inner.GetType().Name}: {inner.Message}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Command '{command.MethodName}' failed: {ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHelpCommand(string verb) =>
|
||||
string.Equals(verb, "help", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(verb, "?", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(verb, "ListRegisteredCommands", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool TryResolveCommandType(ConfiguredCommand command, out Type? type, out string error)
|
||||
{
|
||||
foreach (var name in new[] { command.ObfuscatedDeclaringType, command.DeclaringType })
|
||||
{
|
||||
var normalized = NormalizeTypeName(name);
|
||||
if (string.IsNullOrWhiteSpace(normalized)) continue;
|
||||
type = DebugMod.ResolveType(normalized);
|
||||
if (type is not null)
|
||||
{
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
type = null;
|
||||
error = $"Command '{command.MethodName}' has no resolvable declaring type (declaring='{command.DeclaringType}', obfuscated='{command.ObfuscatedDeclaringType}').";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeTypeName(string value)
|
||||
{
|
||||
value = (value ?? string.Empty).Trim();
|
||||
var comma = value.IndexOf(',');
|
||||
return comma >= 0 ? value[..comma].Trim() : value;
|
||||
}
|
||||
|
||||
private static bool TryFindCallableMethod(Type type, string methodName, List<string> args,
|
||||
out MethodInfo? method, out object?[] converted, out string error)
|
||||
{
|
||||
foreach (var candidate in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
|
||||
{
|
||||
if (candidate.Name != methodName) continue;
|
||||
if (candidate.IsAbstract || candidate.ContainsGenericParameters) continue;
|
||||
if (TryConvertArguments(candidate.GetParameters(), args, out converted))
|
||||
{
|
||||
method = candidate;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
method = null;
|
||||
converted = Array.Empty<object?>();
|
||||
error = $"No overload of {type.Name}.{methodName} accepts {args.Count} argument(s): {string.Join(" ", args)}";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryConvertArguments(ParameterInfo[] parameters, List<string> args, out object?[] converted)
|
||||
{
|
||||
converted = new object?[parameters.Length];
|
||||
var argIndex = 0;
|
||||
|
||||
for (var i = 0; i < parameters.Length; i++)
|
||||
{
|
||||
var parameter = parameters[i];
|
||||
if (IsVector3(parameter.ParameterType) && args.Count - argIndex >= 3)
|
||||
{
|
||||
if (!TryParseFloat(args[argIndex], out var x) ||
|
||||
!TryParseFloat(args[argIndex + 1], out var y) ||
|
||||
!TryParseFloat(args[argIndex + 2], out var z) ||
|
||||
!TryCreateVector3(parameter.ParameterType, x, y, z, out converted[i]))
|
||||
return false;
|
||||
argIndex += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argIndex >= args.Count)
|
||||
{
|
||||
if (parameter.HasDefaultValue)
|
||||
{
|
||||
converted[i] = parameter.DefaultValue;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryConvertArgument(args[argIndex], parameter.ParameterType, out converted[i]))
|
||||
return false;
|
||||
argIndex++;
|
||||
}
|
||||
|
||||
return argIndex == args.Count;
|
||||
}
|
||||
|
||||
private static bool TryConvertArgument(string value, Type targetType, out object? converted)
|
||||
{
|
||||
converted = null;
|
||||
|
||||
if (targetType == typeof(string) || targetType.FullName == "System.String")
|
||||
{
|
||||
converted = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
var nullable = Nullable.GetUnderlyingType(targetType);
|
||||
if (nullable is not null)
|
||||
targetType = nullable;
|
||||
|
||||
if (targetType == typeof(bool))
|
||||
{
|
||||
if (bool.TryParse(value, out var b))
|
||||
{
|
||||
converted = b;
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) ||
|
||||
value == "1")
|
||||
{
|
||||
converted = true;
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "no", StringComparison.OrdinalIgnoreCase) ||
|
||||
value == "0")
|
||||
{
|
||||
converted = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (targetType.IsEnum)
|
||||
{
|
||||
try
|
||||
{
|
||||
converted = Enum.Parse(targetType, value, ignoreCase: true);
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (targetType == typeof(int)) { converted = int.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(long)) { converted = long.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(float)) { converted = float.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(double)) { converted = double.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(short)) { converted = short.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(byte)) { converted = byte.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
}
|
||||
catch { return false; }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsVector3(Type type) =>
|
||||
type.Name == "Vector3" && (type.Namespace == "UnityEngine" || type.FullName?.EndsWith(".Vector3", StringComparison.Ordinal) == true);
|
||||
|
||||
private static bool TryParseFloat(string value, out float result) =>
|
||||
float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result);
|
||||
|
||||
private static bool TryCreateVector3(Type vectorType, float x, float y, float z, out object? vector)
|
||||
{
|
||||
vector = null;
|
||||
try
|
||||
{
|
||||
var ctor = vectorType.GetConstructor(new[] { typeof(float), typeof(float), typeof(float) });
|
||||
if (ctor is null) return false;
|
||||
vector = ctor.Invoke(new object[] { x, y, z });
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static object? FindFirstInstance(Type type)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppType = ToIl2CppType(type);
|
||||
if (il2cppType is null) return null;
|
||||
var resources = DebugMod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var item = ArrItem(all, i);
|
||||
if (item is not null) return item;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatResult(object result)
|
||||
{
|
||||
var text = result.ToString() ?? result.GetType().Name;
|
||||
return text.Length <= 500 ? text : text[..500] + "...";
|
||||
}
|
||||
|
||||
private static string FirstLine(string value)
|
||||
{
|
||||
value = value.Replace("\r", "");
|
||||
var idx = value.IndexOf('\n');
|
||||
return idx >= 0 ? value[..idx] : value;
|
||||
}
|
||||
|
||||
private static List<string> SplitCommandLine(string value)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(value)) return result;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
var quote = '\0';
|
||||
var escaping = false;
|
||||
|
||||
foreach (var ch in value.Trim())
|
||||
{
|
||||
if (escaping)
|
||||
{
|
||||
sb.Append(ch);
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '\\')
|
||||
{
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inQuotes)
|
||||
{
|
||||
if (ch == quote)
|
||||
{
|
||||
inQuotes = false;
|
||||
continue;
|
||||
}
|
||||
sb.Append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch is '"' or '\'')
|
||||
{
|
||||
inQuotes = true;
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(ch))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
result.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(ch);
|
||||
}
|
||||
|
||||
if (escaping) sb.Append('\\');
|
||||
if (sb.Length > 0) result.Add(sb.ToString());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveInput()
|
||||
{
|
||||
_inputResolved = true;
|
||||
var keyCodeType = DebugMod.ResolveType("UnityEngine.KeyCode");
|
||||
var inputType = DebugMod.ResolveType("UnityEngine.Input");
|
||||
if (keyCodeType is null || inputType is null)
|
||||
{
|
||||
DebugMod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
try { _toggleKeyValue = Enum.Parse(keyCodeType, DebugMod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] '{DebugMod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
|
||||
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
|
||||
}
|
||||
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||
if (_getKeyDown is null) DebugMod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
|
||||
else DebugMod.Log.Msg($"[debugconsole] hotkey armed: '{DebugMod.Cfg.DebugConsoleToggleKey}' (poll live)");
|
||||
}
|
||||
|
||||
private static void ResolveConsole()
|
||||
{
|
||||
_consoleResolved = true;
|
||||
_consoleType = DebugMod.ResolveType("RecRoom.Debugging.DebugConsole");
|
||||
if (_consoleType is null) { DebugMod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
|
||||
|
||||
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
|
||||
// name, then a structural fallback that fits both builds.
|
||||
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "KIAMDEDKFBC", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(_consoleType);
|
||||
if (_toggleMethod is null) DebugMod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
}
|
||||
|
||||
// The console's show/hide is the single private, instance, void method
|
||||
// taking exactly one bool, excluding compiler-generated members (the
|
||||
// property backing setter on 2020.12 is also void(bool) but is marked
|
||||
// [CompilerGenerated]).
|
||||
private static MethodInfo? FindToggleByShape(Type t)
|
||||
{
|
||||
MethodInfo? found = null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||
if (found is not null) return null; // ambiguous — don't guess
|
||||
found = m;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static void Toggle()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null || _toggleMethod is null) return;
|
||||
try
|
||||
{
|
||||
var instance = FindConsoleInstance();
|
||||
if (instance is null)
|
||||
{
|
||||
DebugMod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||
return;
|
||||
}
|
||||
// Best-effort: flip the static "show button" enable so the
|
||||
// game's own Update keeps the console available instead of
|
||||
// re-hiding it. Failures are ignored (obfuscated builds).
|
||||
EnableShowButton();
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||
if (_consoleVisible)
|
||||
ShowConfiguredCommands(instance);
|
||||
DebugMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
}
|
||||
catch (Exception ex) { DebugMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static object? FindConsoleInstance()
|
||||
{
|
||||
if (_consoleType is null) return null;
|
||||
try
|
||||
{
|
||||
var il2cppType = ToIl2CppType(_consoleType);
|
||||
if (il2cppType is null) return null;
|
||||
|
||||
var resources = DebugMod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var item = ArrItem(all, i);
|
||||
if (item is not null) return item;
|
||||
}
|
||||
|
||||
var objType = DebugMod.ResolveType("UnityEngine.Object");
|
||||
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
|
||||
return findOne?.Invoke(null, new[] { il2cppType });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] FindConsoleInstance failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowConfiguredCommands(object instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
|
||||
if (_setConsoleText is null)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}");
|
||||
return;
|
||||
}
|
||||
|
||||
_setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() });
|
||||
DebugMod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] failed to display configured commands: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildConfiguredCommandText()
|
||||
{
|
||||
return BuildCommandReferenceText("DorkNet debug commands", maxCommands: 80) + "\n\nType a command and press Enter.";
|
||||
}
|
||||
|
||||
private static string ReadConsoleInput(object consoleInstance)
|
||||
{
|
||||
var input = FindConsoleInput(consoleInstance);
|
||||
return input is null ? string.Empty : ReadText(input);
|
||||
}
|
||||
|
||||
private static void WriteConsoleOutput(object consoleInstance, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_consoleType is not null)
|
||||
{
|
||||
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
|
||||
if (_setConsoleText is not null && _setConsoleText.DeclaringType?.IsInstanceOfType(consoleInstance) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
_setConsoleText.Invoke(consoleInstance, new object[] { text });
|
||||
return;
|
||||
}
|
||||
catch (TargetException)
|
||||
{
|
||||
// The live IL2CPP proxy can differ from the resolved wrapper type.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var input = FindConsoleInput(consoleInstance);
|
||||
if (input is not null)
|
||||
SetText(input, text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] output write failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object? FindConsoleInput(object consoleInstance)
|
||||
{
|
||||
foreach (var name in new[] { "KECJGKLKFGP", "inputField", "InputField" })
|
||||
{
|
||||
var value = GetMemberValue(consoleInstance, name);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
|
||||
var type = consoleInstance.GetType();
|
||||
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
if (!LooksLikeInputField(prop.PropertyType) || !prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try { if (prop.GetValue(consoleInstance) is { } value) return value; } catch { }
|
||||
}
|
||||
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
if (!LooksLikeInputField(field.FieldType)) continue;
|
||||
try { if (field.GetValue(consoleInstance) is { } value) return value; } catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool LooksLikeInputField(Type type) =>
|
||||
type.Name.IndexOf("InputField", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
type.FullName?.IndexOf("InputField", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
private static string ReadText(object textObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = textObject.GetType();
|
||||
var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?? type.GetProperty("Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop is not null && prop.CanRead && prop.GetValue(textObject) is string s)
|
||||
return s;
|
||||
|
||||
var getter = type.GetMethod("get_text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null)
|
||||
?? type.GetMethod("get_Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null);
|
||||
return getter?.Invoke(textObject, Array.Empty<object>()) as string ?? string.Empty;
|
||||
}
|
||||
catch { return string.Empty; }
|
||||
}
|
||||
|
||||
private static void SetText(object textObject, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = textObject.GetType();
|
||||
var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?? type.GetProperty("Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop is not null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(textObject, text);
|
||||
return;
|
||||
}
|
||||
|
||||
var setter = type.GetMethod("set_text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null)
|
||||
?? type.GetMethod("set_Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null);
|
||||
setter?.Invoke(textObject, new object[] { text });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static string BuildCommandReferenceText(string header, int maxCommands = int.MaxValue)
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(header);
|
||||
sb.AppendLine();
|
||||
if (commands.Count == 0)
|
||||
{
|
||||
sb.AppendLine("No DebugConsoleCommandConfig commands found.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var visible = Math.Min(commands.Count, maxCommands);
|
||||
for (var i = 0; i < visible; i++)
|
||||
sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}");
|
||||
if (visible < commands.Count)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"+ {commands.Count - visible} more commands available through the debug console.");
|
||||
}
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
internal static List<ConfiguredCommand> GetAvailableCommands()
|
||||
{
|
||||
if (_commandCache is not null) return _commandCache;
|
||||
|
||||
var commands = new List<ConfiguredCommand>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AddConfiguredCommands(commands, seen);
|
||||
AddRuntimeCommands(commands, seen);
|
||||
commands.Sort((a, b) => string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase));
|
||||
_commandCache = commands;
|
||||
DebugMod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)");
|
||||
return commands;
|
||||
}
|
||||
|
||||
// Read-only one-shot diagnostic (Cfg.DumpDevCommands). Writes a full report
|
||||
// to UserData/dorknet-devcommands.txt: does a live DebugConsole exist, and
|
||||
// for every runtime command meta, does its declaring type resolve and does
|
||||
// a method with that name exist on it (i.e. is it actually callable on this
|
||||
// obfuscated build). Invokes NOTHING and touches no game UI — safe to run
|
||||
// in a live session. This is what turns the text-console path from guessing
|
||||
// into engineering: the resolvable/total tally says whether the feature is
|
||||
// buildable as-is or needs an obfuscation name map.
|
||||
// Returns true once it observes a populated runtime command table (so the
|
||||
// caller can stop retrying); false while the config singleton isn't up yet.
|
||||
public static bool DumpCommandDiagnostics()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int runtime = 0;
|
||||
void W(string s) { sb.Append(s).Append('\n'); DebugMod.Log.Msg("[devcmd] " + s); }
|
||||
|
||||
try
|
||||
{
|
||||
// Force a fresh scan — an early call (before the config singleton
|
||||
// loads) must not poison the cache with an empty table.
|
||||
_commandCache = null;
|
||||
W("=== DorkNet dev-command diagnostic ===");
|
||||
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
object? liveConsole = null;
|
||||
try { liveConsole = FindConsoleInstance(); } catch (Exception ex) { W($"FindConsoleInstance threw: {ex.Message}"); }
|
||||
W($"DebugConsole type resolved : {_consoleType?.FullName ?? "<none>"}");
|
||||
W($"DebugConsole live instance : {(liveConsole is null ? "NULL (UI shell not instantiated — text console can't be shown, would need our own UI)" : "FOUND (UI can be driven)")}");
|
||||
W($"toggle method : {_toggleMethod?.Name ?? "<none>"}");
|
||||
W("");
|
||||
|
||||
List<ConfiguredCommand> commands;
|
||||
try { commands = GetAvailableCommands(); }
|
||||
catch (Exception ex) { W($"GetAvailableCommands threw: {ex}"); commands = new List<ConfiguredCommand>(); }
|
||||
|
||||
int typeOk = 0, methodOk = 0;
|
||||
var samplesOk = new List<string>();
|
||||
var samplesBad = new List<string>();
|
||||
|
||||
foreach (var c in commands)
|
||||
{
|
||||
if (!c.HasRuntimeTarget) continue;
|
||||
runtime++;
|
||||
|
||||
var resolvedType = TryResolveCommandType(c, out var t, out _) ? t : null;
|
||||
if (resolvedType is null)
|
||||
{
|
||||
if (samplesBad.Count < 30)
|
||||
samplesBad.Add($" [no-type] {c.MethodName} (declaring='{c.DeclaringType}' obf='{c.ObfuscatedDeclaringType}')");
|
||||
continue;
|
||||
}
|
||||
typeOk++;
|
||||
|
||||
var hasMethod = false;
|
||||
foreach (var m in resolvedType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
|
||||
if (m.Name == c.MethodName) { hasMethod = true; break; }
|
||||
|
||||
if (hasMethod)
|
||||
{
|
||||
methodOk++;
|
||||
if (samplesOk.Count < 40) samplesOk.Add($" [OK] {resolvedType.Name}.{c.MethodName}");
|
||||
}
|
||||
else if (samplesBad.Count < 30)
|
||||
{
|
||||
samplesBad.Add($" [no-method] {resolvedType.Name}.{c.MethodName} (type resolved, method name not present → obfuscated/stripped)");
|
||||
}
|
||||
}
|
||||
|
||||
W($"total commands in table : {commands.Count}");
|
||||
W($"runtime-target commands : {runtime}");
|
||||
W($" declaring type resolves : {typeOk}");
|
||||
W($" method callable by name : {methodOk} <-- this number decides feasibility");
|
||||
W("");
|
||||
W("--- sample CALLABLE commands ---");
|
||||
foreach (var s in samplesOk) W(s);
|
||||
W("");
|
||||
W("--- sample UNRESOLVED commands ---");
|
||||
foreach (var s in samplesBad) W(s);
|
||||
W("");
|
||||
W("--- FULL runtime command list (label | methodName | declaringType | obfuscatedDeclaringType) ---");
|
||||
foreach (var c in commands)
|
||||
{
|
||||
if (!c.HasRuntimeTarget) continue;
|
||||
sb.Append(" ").Append(c.Label).Append(" | ").Append(c.MethodName)
|
||||
.Append(" | ").Append(c.DeclaringType).Append(" | ").Append(c.ObfuscatedDeclaringType).Append('\n');
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sb.Append("DIAGNOSTIC FAILED: ").Append(ex).Append('\n');
|
||||
DebugMod.Log.Warning($"[devcmd] diagnostic failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Only persist the report once the table is actually populated, so an
|
||||
// early empty attempt doesn't overwrite a good report from a later try.
|
||||
if (runtime > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(DebugMod.DiagnosticsLogPath) ?? ".";
|
||||
var outPath = Path.Combine(dir, "dorknet-devcommands.txt");
|
||||
File.WriteAllText(outPath, sb.ToString());
|
||||
DebugMod.Log.Msg($"[devcmd] wrote {outPath} ({runtime} runtime commands)");
|
||||
}
|
||||
catch (Exception ex) { DebugMod.Log.Warning($"[devcmd] could not write report: {ex.Message}"); }
|
||||
}
|
||||
else
|
||||
{
|
||||
DebugMod.Log.Msg("[devcmd] command table not populated yet — will retry");
|
||||
}
|
||||
|
||||
return runtime > 0;
|
||||
}
|
||||
|
||||
private static bool TryFindCommand(string verb, out ConfiguredCommand command)
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
foreach (var candidate in commands)
|
||||
{
|
||||
if (!candidate.HasRuntimeTarget) continue;
|
||||
if (CommandMatches(candidate, verb))
|
||||
{
|
||||
command = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
foreach (var candidate in commands)
|
||||
{
|
||||
if (CommandMatches(candidate, verb))
|
||||
{
|
||||
command = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
command = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CommandMatches(ConfiguredCommand command, string verb)
|
||||
{
|
||||
if (string.Equals(command.MethodName, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(command.Command, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(command.Label, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
var shortLabel = command.Label;
|
||||
var dot = shortLabel.LastIndexOf('.');
|
||||
if (dot >= 0 && dot < shortLabel.Length - 1)
|
||||
shortLabel = shortLabel[(dot + 1)..];
|
||||
if (string.Equals(shortLabel, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
var commandTokens = SplitCommandLine(command.Command);
|
||||
return commandTokens.Count > 0 && string.Equals(commandTokens[0], verb, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void AddConfiguredCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
foreach (var entry in DebugMod.Cfg.DevCommands)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||
var split = entry.IndexOf('=');
|
||||
var label = split >= 0 ? entry[..split].Trim() : entry.Trim();
|
||||
var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim();
|
||||
if (string.IsNullOrWhiteSpace(command)) continue;
|
||||
if (string.IsNullOrWhiteSpace(label)) label = command;
|
||||
var parts = SplitCommandLine(command);
|
||||
var methodName = parts.Count > 0 ? parts[0] : command;
|
||||
AddCommand(commands, seen, label, command, declaringType: string.Empty, obfuscatedDeclaringType: string.Empty, methodName, isRuntime: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRuntimeCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfgType = DebugMod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig");
|
||||
if (cfgType is null)
|
||||
{
|
||||
DebugMod.Log.Warning("[debugconsole] DebugConsoleCommandConfig type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var method in cfgType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (method.GetParameters().Length != 0) continue;
|
||||
if (!cfgType.IsAssignableFrom(method.ReturnType)) continue;
|
||||
try { AddCommandConfig(method.Invoke(null, Array.Empty<object>()), commands, seen); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
var il2cppType = ToIl2CppType(cfgType);
|
||||
var resources = DebugMod.ResolveType("UnityEngine.Resources");
|
||||
if (il2cppType is not null && resources is not null)
|
||||
{
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
AddCommandConfig(ArrItem(all, i), commands, seen);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] runtime command scan failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCommandConfig(object? config, List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
if (config is null) return;
|
||||
var metas = GetMemberValue(config, "Metas");
|
||||
var count = ArrLen(metas);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var meta = ArrItem(metas, i);
|
||||
if (meta is null) continue;
|
||||
var methodName = ReadStringMember(meta, "MethodName");
|
||||
if (string.IsNullOrWhiteSpace(methodName)) continue;
|
||||
var declaringType = ReadStringMember(meta, "DeclaringType");
|
||||
var obfuscatedDeclaringType = ReadStringMember(meta, "ObfuscatedDeclaringType");
|
||||
var label = string.IsNullOrWhiteSpace(declaringType)
|
||||
? methodName
|
||||
: $"{ShortTypeName(declaringType)}.{methodName}";
|
||||
AddCommand(commands, seen, label, methodName, declaringType, obfuscatedDeclaringType, methodName, isRuntime: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCommand(List<ConfiguredCommand> commands, HashSet<string> seen, string label, string command,
|
||||
string declaringType, string obfuscatedDeclaringType, string methodName, bool isRuntime)
|
||||
{
|
||||
label = CleanLabel(label);
|
||||
command = (command ?? string.Empty).Trim();
|
||||
methodName = (methodName ?? string.Empty).Trim();
|
||||
if (command.Length == 0 || methodName.Length == 0) return;
|
||||
|
||||
var key = isRuntime
|
||||
? $"{NormalizeTypeName(obfuscatedDeclaringType)}|{NormalizeTypeName(declaringType)}|{methodName}"
|
||||
: $"configured|{label}|{command}";
|
||||
if (!seen.Add(key)) return;
|
||||
|
||||
commands.Add(new ConfiguredCommand(label, command, declaringType, obfuscatedDeclaringType, methodName, isRuntime));
|
||||
}
|
||||
|
||||
private static object? GetMemberValue(object instance, string name)
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
|
||||
{
|
||||
try { return prop.GetValue(instance); } catch { }
|
||||
}
|
||||
|
||||
var field = type.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field is not null)
|
||||
{
|
||||
try { return field.GetValue(instance); } catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReadStringMember(object instance, string name)
|
||||
{
|
||||
return GetMemberValue(instance, name)?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string CleanLabel(string label)
|
||||
{
|
||||
label = (label ?? string.Empty).Trim();
|
||||
if (label.Length == 0) return "Command";
|
||||
|
||||
var cleaned = new char[label.Length];
|
||||
for (var i = 0; i < label.Length; i++)
|
||||
{
|
||||
var ch = label[i];
|
||||
cleaned[i] = char.IsLetterOrDigit(ch) || ch is '.' or '_' or '-' or ' ' ? ch : '_';
|
||||
}
|
||||
|
||||
return new string(cleaned);
|
||||
}
|
||||
|
||||
private static string ShortTypeName(string typeName)
|
||||
{
|
||||
var idx = typeName.LastIndexOf('.');
|
||||
return idx >= 0 && idx < typeName.Length - 1 ? typeName[(idx + 1)..] : typeName;
|
||||
}
|
||||
|
||||
internal readonly struct ConfiguredCommand
|
||||
{
|
||||
public ConfiguredCommand(string label, string command, string declaringType,
|
||||
string obfuscatedDeclaringType, string methodName, bool hasRuntimeTarget)
|
||||
{
|
||||
Label = label;
|
||||
Command = command;
|
||||
DeclaringType = declaringType;
|
||||
ObfuscatedDeclaringType = obfuscatedDeclaringType;
|
||||
MethodName = methodName;
|
||||
HasRuntimeTarget = hasRuntimeTarget;
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
public string Command { get; }
|
||||
public string DeclaringType { get; }
|
||||
public string ObfuscatedDeclaringType { get; }
|
||||
public string MethodName { get; }
|
||||
public bool HasRuntimeTarget { get; }
|
||||
}
|
||||
|
||||
private static void EnableShowButton()
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
|
||||
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
|
||||
{
|
||||
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
|
||||
}
|
||||
}
|
||||
catch { /* best-effort — the toggle still works without it */ }
|
||||
}
|
||||
|
||||
private static object? ToIl2CppType(Type managed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppTypeType = DebugMod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
|
||||
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
|
||||
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
|
||||
if (from is null) return null;
|
||||
var args = from.GetParameters().Length == 2
|
||||
? new object[] { managed, true }
|
||||
: new object[] { managed };
|
||||
return from.Invoke(null, args);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static MethodInfo? OneArgMethod(Type? type, string name, object arg)
|
||||
{
|
||||
if (type is null) return null;
|
||||
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (method.Name != name) continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length == 1 && parameters[0].ParameterType.IsInstanceOfType(arg))
|
||||
return method;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ArrLen(object? array)
|
||||
{
|
||||
if (array is null) return 0;
|
||||
if (array is Array managedArray) return managedArray.Length;
|
||||
var type = array.GetType();
|
||||
var length = type.GetProperty("Length") ?? type.GetProperty("Count");
|
||||
return (length?.GetValue(array) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static object? ArrItem(object? array, int index)
|
||||
{
|
||||
if (array is null) return null;
|
||||
if (array is Array managedArray) return managedArray.GetValue(index);
|
||||
var getItem = array.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return getItem?.Invoke(array, new object[] { index });
|
||||
}
|
||||
}
|
||||
internal static class MakerPenGiftPreviewPatches
|
||||
{
|
||||
private static bool _loggedMissingPointer;
|
||||
private static bool _loggedMissingRestrictionFlag;
|
||||
|
||||
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__instance is null) return true;
|
||||
var t = __instance.GetType();
|
||||
if (ReadField(__instance, t, "pointerLineRenderer") is null)
|
||||
{
|
||||
if (!_loggedMissingPointer)
|
||||
{
|
||||
_loggedMissingPointer = true;
|
||||
DebugMod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
|
||||
{
|
||||
if (!_loggedMissingRestrictionFlag)
|
||||
{
|
||||
_loggedMissingRestrictionFlag = true;
|
||||
DebugMod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object? ReadField(object instance, Type type, string name)
|
||||
{
|
||||
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
|
||||
return field?.GetValue(instance);
|
||||
}
|
||||
}
|
||||
internal static class AuthPatches
|
||||
{
|
||||
private static string? _latestAccessToken;
|
||||
private static int _tokenLogCount;
|
||||
|
||||
public static void CaptureTokenArgs_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var accessToken = __0?.ToString();
|
||||
if (LooksLikeJwt(accessToken))
|
||||
_latestAccessToken = accessToken;
|
||||
|
||||
if (_tokenLogCount++ < 3)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
"[auth-trace] token setter " +
|
||||
$"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[auth-trace] token capture failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LooksLikeJwt(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
|
||||
var dots = 0;
|
||||
foreach (var ch in value)
|
||||
if (ch == '.') dots++;
|
||||
return dots == 2;
|
||||
}
|
||||
|
||||
private static string Presence(object? value) =>
|
||||
string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present";
|
||||
}
|
||||
internal static class HttpTracePatches
|
||||
{
|
||||
private const int BodyPreviewBytes = 700;
|
||||
private static readonly Regex SensitivePairRegex = new(
|
||||
@"(?i)(password|client_secret|access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex BearerRegex = new(
|
||||
@"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex SensitiveHeaderRegex = new(
|
||||
@"(?im)^(Authorization|Cookie|Set-Cookie|X-RNSIG):\s*[^\r\n]+",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex SensitiveJsonStringRegex = new(
|
||||
@"(?i)""([^""]*(?:access_token|accessToken|refresh_token|refreshToken|authorization|token|secret|client_secret|key)[^""]*)""\s*:\s*""[^""]*""",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Dictionary<IntPtr, string> RecNetRequestInfoByPtr = new();
|
||||
private static int _requestId;
|
||||
private static int _failureLogCount;
|
||||
private static int _responseTupleLogCount;
|
||||
private static int _builderSendLogCount;
|
||||
private static int _callbackLogCount;
|
||||
private static int _uriUnreadDumpCount;
|
||||
|
||||
public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
TraceRequestObject(__instance, $"{__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||
}
|
||||
|
||||
public static void RecNetRequestBuilderCtor_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
var id = NextRequestId();
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-trace] #{id} BNDIAONDFFF.ctor method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
|
||||
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}");
|
||||
}
|
||||
|
||||
public static void RecNetRequestBuilderCtor_Postfix(object __instance, object? __0, object? __1, object? __2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(__instance);
|
||||
if (ptr == IntPtr.Zero) return;
|
||||
|
||||
var info =
|
||||
$"ptr=0x{ptr.ToInt64():X} method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
|
||||
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}";
|
||||
lock (RecNetRequestInfoByPtr)
|
||||
{
|
||||
RecNetRequestInfoByPtr[ptr] = info;
|
||||
if (RecNetRequestInfoByPtr.Count > 256)
|
||||
RecNetRequestInfoByPtr.Clear();
|
||||
}
|
||||
|
||||
DiagnosticPatches.Write($"[recnet-request-map] {info}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-request-map", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetBuilderSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (System.Threading.Interlocked.Increment(ref _builderSendLogCount) > 120) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-send] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-send-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetBuilderSend_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (_builderSendLogCount > 120) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-send] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-send-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetResponseTuple_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (System.Threading.Interlocked.Increment(ref _responseTupleLogCount) > 80) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-response-map-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetResponseTuple_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (_responseTupleLogCount > 80) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-response-map-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static Exception? RecNetResponseTuple_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
|
||||
$"state={DumpObjectFields(__instance, 36, 1200)}");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = __0?.ToString() ?? "<null>";
|
||||
var value = RedactHeaderValue(name, __1?.ToString());
|
||||
var uri = TryReadRequestUri(__instance)?.ToString() ?? "<uri unread>";
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-header] {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name} " +
|
||||
$"{SanitizeInline(uri)} {name}: {value}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("header", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CallCallback_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CallCallback_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ShouldLogCallback(__instance)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static Exception? CallCallback_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)} " +
|
||||
$"{RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
|
||||
$"state={DumpObjectFields(__instance, 32, 900)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-finalizer", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void TraceUrl(string? url, string source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var id = NextRequestId();
|
||||
DiagnosticPatches.Write($"[http-trace] #{id} {source} url={SanitizeInline(url)}");
|
||||
}
|
||||
|
||||
public static void TraceRequestObject(object? request, string source)
|
||||
{
|
||||
if (request is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var id = NextRequestId();
|
||||
var uriValue = TryReadRequestUri(request);
|
||||
var uri = uriValue?.ToString() ?? "<uri unread>";
|
||||
var method = TryReadHttpMethod(request);
|
||||
var headers = TryReadHeaders(request);
|
||||
var body = TryReadBodyPreview(request);
|
||||
var fallback = uriValue is null && System.Threading.Interlocked.Increment(ref _uriUnreadDumpCount) <= 40
|
||||
? $" requestFields={DumpObjectFields(request, 24, 260)}"
|
||||
: "";
|
||||
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " +
|
||||
$"headers={headers} body={body}{fallback}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure(source, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static int NextRequestId() => System.Threading.Interlocked.Increment(ref _requestId);
|
||||
|
||||
private static Uri? TryReadRequestUri(object request)
|
||||
{
|
||||
if (TryInvokeUriMethod(request, "KPKNHFBHPHO", out var uri)) return uri;
|
||||
if (TryInvokeUriMethod(request, "IBLHNHHBNNE", out uri)) return uri;
|
||||
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
var type = request.GetType();
|
||||
|
||||
if (TryReadUriValue(type.GetProperty("Uri", flags)?.GetValue(request), out uri)) return uri;
|
||||
if (TryReadUriValue(type.GetProperty("CurrentUri", flags)?.GetValue(request), out uri)) return uri;
|
||||
if (TryReadUriValue(type.GetField("<Uri>k__BackingField", flags)?.GetValue(request), out uri)) return uri;
|
||||
|
||||
foreach (var prop in type.GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try
|
||||
{
|
||||
if (TryReadUriValue(prop.GetValue(request), out uri)) return uri;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
try
|
||||
{
|
||||
if (TryReadUriValue(field.GetValue(request), out uri)) return uri;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryInvokeUriMethod(object request, string methodName, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
|
||||
if (method is null) return false;
|
||||
return TryReadUriValue(method.Invoke(request, Array.Empty<object>()), out uri);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadUriValue(object? value, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
if (value is null) return false;
|
||||
if (value is Uri direct)
|
||||
{
|
||||
uri = direct;
|
||||
return true;
|
||||
}
|
||||
|
||||
var text = value.ToString();
|
||||
if (Uri.TryCreate(text, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
uri = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string TryReadHttpMethod(object request)
|
||||
{
|
||||
foreach (var name in new[] { "MethodType", "Method", "LNJILMKOBIP" })
|
||||
{
|
||||
if (TryReadMemberValue(request, name, out var value))
|
||||
return SanitizeInline(value?.ToString() ?? "<null>");
|
||||
}
|
||||
|
||||
return "<method unread>";
|
||||
}
|
||||
|
||||
private static string TryReadHeaders(object request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dump = AccessTools.Method(request.GetType(), "DumpHeaders", Type.EmptyTypes);
|
||||
if (dump?.Invoke(request, Array.Empty<object>()) is object rawDump)
|
||||
return SanitizeInline(rawDump.ToString() ?? "<empty>");
|
||||
}
|
||||
catch { }
|
||||
|
||||
var known = new List<string>();
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"Authorization", "Cookie", "X-RNSIG", "Accept-Language",
|
||||
"Content-Type", "User-Agent"
|
||||
})
|
||||
{
|
||||
var value = TryReadHeaderValue(request, name);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
known.Add($"{name}:{RedactHeaderValue(name, value)}");
|
||||
}
|
||||
|
||||
return known.Count == 0 ? "<headers unread>" : string.Join("; ", known);
|
||||
}
|
||||
|
||||
private static string? TryReadHeaderValue(object request, string name)
|
||||
{
|
||||
foreach (var methodName in new[] { "GetFirstHeaderValue", "FKHEHIMHHBP" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, new[] { typeof(string) });
|
||||
var value = method?.Invoke(request, new object[] { name })?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string TryReadBodyPreview(object request)
|
||||
{
|
||||
foreach (var methodName in new[] { "BGMFJOKNJAJ", "GetEntityBody" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
|
||||
if (method is null) continue;
|
||||
var body = method.Invoke(request, Array.Empty<object>());
|
||||
var bytes = TryCopyBytes(body, BodyPreviewBytes + 1);
|
||||
if (bytes is null) continue;
|
||||
if (bytes.Length == 0) return "<empty>";
|
||||
|
||||
var shown = Math.Min(bytes.Length, BodyPreviewBytes);
|
||||
var text = Encoding.UTF8.GetString(bytes, 0, shown);
|
||||
var suffix = bytes.Length > BodyPreviewBytes ? "...<truncated>" : "";
|
||||
return $"{bytes.Length} bytes \"{SanitizeInline(text)}{suffix}\"";
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return "<body unread>";
|
||||
}
|
||||
|
||||
private static bool ShouldLogCallback(object request)
|
||||
{
|
||||
var response = TryReadResponseObject(request);
|
||||
var status = response is null ? null : TryReadStatusCode(response);
|
||||
if (status >= 400) return true;
|
||||
return System.Threading.Interlocked.Increment(ref _callbackLogCount) <= 80;
|
||||
}
|
||||
|
||||
private static string DescribeRequestAndResponse(object request)
|
||||
{
|
||||
var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>";
|
||||
var method = TryReadHttpMethod(request);
|
||||
var response = TryReadResponseObject(request);
|
||||
var responseText = response is null ? "<response unread>" : DescribeResponseObject(response);
|
||||
var requestState = DescribeKnownMembers(request, "State", "Exception", "Error", "IsCancellationRequested");
|
||||
return $"method={method} uri={SanitizeInline(uri)} request={requestState} response={responseText}";
|
||||
}
|
||||
|
||||
private static object? TryReadResponseObject(object request)
|
||||
{
|
||||
foreach (var name in new[] { "Response", "response", "_response", "<Response>k__BackingField" })
|
||||
{
|
||||
if (TryReadMemberValue(request, name, out var value) && value is not null)
|
||||
return value;
|
||||
}
|
||||
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var prop in request.GetType().GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try
|
||||
{
|
||||
var value = prop.GetValue(request);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
foreach (var field in request.GetType().GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
try
|
||||
{
|
||||
var value = field.GetValue(request);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? TryReadStatusCode(object response)
|
||||
{
|
||||
if (TryReadMemberValue(response, "StatusCode", out var value))
|
||||
{
|
||||
try { return Convert.ToInt32(value); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string DescribeResponseObject(object response)
|
||||
{
|
||||
var known = DescribeKnownMembers(response,
|
||||
"StatusCode", "Message", "IsSuccess", "DataAsText", "Data", "Exception", "Error");
|
||||
return $"{response.GetType().FullName} {{{known}}}";
|
||||
}
|
||||
|
||||
private static string DescribeKnownMembers(object obj, params string[] names)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryReadMemberValue(obj, name, out var value)) continue;
|
||||
parts.Add($"{name}={FormatValue(value, 700)}");
|
||||
}
|
||||
return parts.Count == 0 ? "<no known members>" : string.Join("; ", parts);
|
||||
}
|
||||
|
||||
private static string FormatValue(object? value, int limit)
|
||||
{
|
||||
if (value is null) return "null";
|
||||
if (value is byte[] bytes)
|
||||
return $"byte[{bytes.Length}]";
|
||||
if (value is IEnumerable enumerable && value is not string)
|
||||
return FormatEnumerable(enumerable, value.GetType(), limit);
|
||||
var text = value is Exception ex
|
||||
? $"{ex.GetType().Name}: {ex.Message}"
|
||||
: value.ToString() ?? "<null>";
|
||||
text = SanitizeInline(text);
|
||||
return text.Length <= limit ? text : text[..limit] + "...<truncated>";
|
||||
}
|
||||
|
||||
private static string FormatEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var countText = TryReadEnumerableCount(enumerable, out var count) ? count.ToString() : "?";
|
||||
var shown = 0;
|
||||
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (shown++ >= 6)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
parts.Add(FormatValue(item, 180));
|
||||
}
|
||||
|
||||
var rendered = $"{type.FullName} count={countText} [{string.Join(", ", parts)}]";
|
||||
return rendered.Length <= limit ? rendered : rendered[..limit] + "...<truncated>";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"{type.FullName} <enumeration failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadEnumerableCount(IEnumerable enumerable, out int count)
|
||||
{
|
||||
count = 0;
|
||||
if (enumerable is ICollection collection)
|
||||
{
|
||||
count = collection.Count;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var prop = enumerable.GetType().GetProperty("Count", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop?.GetValue(enumerable) is int value)
|
||||
{
|
||||
count = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static byte[]? TryCopyBytes(object? value, int limit)
|
||||
{
|
||||
if (value is null) return null;
|
||||
if (value is byte[] direct)
|
||||
return CopyLimited(direct, limit);
|
||||
|
||||
if (value is IEnumerable enumerable)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (item is null) continue;
|
||||
try { bytes.Add(Convert.ToByte(item)); }
|
||||
catch { return null; }
|
||||
if (bytes.Count >= limit) break;
|
||||
}
|
||||
return bytes.ToArray();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] CopyLimited(byte[] source, int limit)
|
||||
{
|
||||
var count = Math.Min(source.Length, limit);
|
||||
var copy = new byte[count];
|
||||
Array.Copy(source, copy, count);
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static bool TryReadMemberValue(object request, string name, out object? value)
|
||||
{
|
||||
value = null;
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
var type = request.GetType();
|
||||
|
||||
try
|
||||
{
|
||||
var prop = type.GetProperty(name, flags);
|
||||
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
|
||||
{
|
||||
value = prop.GetValue(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var field = type.GetField(name, flags);
|
||||
if (field is not null)
|
||||
{
|
||||
value = field.GetValue(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(type, name, Type.EmptyTypes);
|
||||
if (method is not null)
|
||||
{
|
||||
value = method.Invoke(request, Array.Empty<object>());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string DumpObjectFields(object? instance, int maxFields, int valueLimit)
|
||||
{
|
||||
if (instance is null) return "<null>";
|
||||
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? value;
|
||||
try { value = field.GetValue(instance); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{field.Name}={FormatValue(value, valueLimit)}");
|
||||
}
|
||||
|
||||
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<field dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string LookupRecNetRequestInfo(object instance)
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(instance);
|
||||
if (ptr == IntPtr.Zero) return "ptr=<unread>";
|
||||
|
||||
lock (RecNetRequestInfoByPtr)
|
||||
{
|
||||
return RecNetRequestInfoByPtr.TryGetValue(ptr, out var info)
|
||||
? info
|
||||
: $"ptr=0x{ptr.ToInt64():X} request=<unmapped>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DumpBndNativeFields(object? instance)
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(instance);
|
||||
if (ptr == IntPtr.Zero) return "native=<unread>";
|
||||
|
||||
try
|
||||
{
|
||||
var method = Marshal.ReadByte(ptr, 16);
|
||||
var service = Marshal.ReadInt32(ptr, 20);
|
||||
var pathPtr = Marshal.ReadIntPtr(ptr, 24);
|
||||
var fieldsPtr = Marshal.ReadIntPtr(ptr, 32);
|
||||
var filesPtr = Marshal.ReadIntPtr(ptr, 40);
|
||||
var objectFieldsPtr = Marshal.ReadIntPtr(ptr, 48);
|
||||
var bodyKind = Marshal.ReadInt32(ptr, 56);
|
||||
var callbackPtr = Marshal.ReadIntPtr(ptr, 64);
|
||||
var path = ReadIl2CppString(pathPtr);
|
||||
|
||||
return
|
||||
"native={" +
|
||||
$"method={method}; service={service}; path={SanitizeInline(path)}; " +
|
||||
$"pathPtr=0x{pathPtr.ToInt64():X}; fieldsPtr=0x{fieldsPtr.ToInt64():X}; " +
|
||||
$"filesPtr=0x{filesPtr.ToInt64():X}; objectFieldsPtr=0x{objectFieldsPtr.ToInt64():X}; " +
|
||||
$"bodyKind={bodyKind}; callbackPtr=0x{callbackPtr.ToInt64():X}" +
|
||||
"}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"native=<dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadIl2CppString(IntPtr ptr)
|
||||
{
|
||||
if (ptr == IntPtr.Zero) return "<null>";
|
||||
|
||||
try
|
||||
{
|
||||
return IL2CPP.Il2CppStringToManaged(ptr) ?? "<null>";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<string read failed 0x{ptr.ToInt64():X} {ex.GetType().Name}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr TryGetIl2CppPtr(object? instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
return instance is Il2CppObjectBase il2CppObject
|
||||
? IL2CPP.Il2CppObjectBaseToPtrNotNull(il2CppObject)
|
||||
: IntPtr.Zero;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RedactHeaderValue(string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "<empty>";
|
||||
if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("X-RNSIG", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "<redacted>";
|
||||
}
|
||||
|
||||
return SanitizeInline(value);
|
||||
}
|
||||
|
||||
private static string SanitizeInline(string value)
|
||||
{
|
||||
var redacted = SensitivePairRegex.Replace(value, "$1=<redacted>");
|
||||
redacted = BearerRegex.Replace(redacted, "Bearer <redacted>");
|
||||
redacted = SensitiveHeaderRegex.Replace(redacted, "$1: <redacted>");
|
||||
redacted = SensitiveJsonStringRegex.Replace(redacted, "\"$1\":\"<redacted>\"");
|
||||
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
return redacted.Length <= 1200 ? redacted : redacted[..1200] + "...<truncated>";
|
||||
}
|
||||
|
||||
private static void LogFailure(string source, Exception ex)
|
||||
{
|
||||
if (_failureLogCount++ < 10)
|
||||
DebugMod.Log.Warning($"[http-trace] {source} failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
internal static class SavePatches
|
||||
{
|
||||
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
|
||||
// our currently-loaded room. Captured BEFORE the watch's caching
|
||||
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
|
||||
// (roomId, subRoomId) tuple — which is every post-save push for a
|
||||
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
|
||||
// would-be no-op setter call into a real transition.
|
||||
private static volatile string? s_lastPushedBlobName;
|
||||
private static long s_lastPushedRoomId;
|
||||
private static long s_lastPushedSubRoomId;
|
||||
|
||||
// Hook on Notifications.OnNotificationReceived(string json). Fires for
|
||||
// every SignalR message before any handler dispatch. Parses the JSON
|
||||
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
|
||||
public static void OnNotificationReceived_Prefix(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
// Fast reject: only parse JSON if it could plausibly be a
|
||||
// SubscriptionUpdateRoom payload. Saves cycles on the (very
|
||||
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
|
||||
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
|
||||
return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!root.TryGetProperty("Id", out var idEl) ||
|
||||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
|
||||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
|
||||
return;
|
||||
if (!root.TryGetProperty("Msg", out var msg) ||
|
||||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!msg.TryGetProperty("Room", out var room) ||
|
||||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
long roomId = 0;
|
||||
if (room.TryGetProperty("RoomId", out var ridEl) &&
|
||||
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
roomId = ridEl.GetInt64();
|
||||
if (!msg.TryGetProperty("Scenes", out var scenes) ||
|
||||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
|
||||
scenes.GetArrayLength() == 0) return;
|
||||
var first = scenes[0];
|
||||
long subRoomId = 0;
|
||||
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
|
||||
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
subRoomId = sidEl.GetInt64();
|
||||
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
|
||||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
|
||||
var blob = blobEl.GetString();
|
||||
if (string.IsNullOrEmpty(blob)) return;
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = blob;
|
||||
DebugMod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void OnSubscriptionUpdateRoom_Prefix(object message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message is null) return;
|
||||
var dict = message;
|
||||
|
||||
// The watch parses this dict as Dictionary<string, object>
|
||||
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
|
||||
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
|
||||
// get_Item(key) — call it via reflection.
|
||||
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (getItem is null) return;
|
||||
|
||||
// The push wire shape from server (BuildRoomDetails) is
|
||||
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
|
||||
// on the Room sub-object; the scene's blob on Scenes[0].
|
||||
object? roomObj = TryGet(dict, getItem, "Room");
|
||||
object? scenesObj = TryGet(dict, getItem, "Scenes");
|
||||
if (roomObj is null || scenesObj is null) return;
|
||||
|
||||
long roomId = 0;
|
||||
long subRoomId = 0;
|
||||
try
|
||||
{
|
||||
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (roomItem is not null)
|
||||
{
|
||||
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
|
||||
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* roomId stays 0 — we'll log it and bail later */ }
|
||||
|
||||
// Scenes is an IL2CPP List<RoomScene>. Take the first
|
||||
// (current dorm has one scene; multi-scene rooms target
|
||||
// Scenes[currentSubRoomId] specifically, but the post-save
|
||||
// push always carries the scene that was just saved).
|
||||
object? firstScene = null;
|
||||
try
|
||||
{
|
||||
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
|
||||
}
|
||||
catch { /* leave firstScene null */ }
|
||||
if (firstScene is null) return;
|
||||
|
||||
string? newBlob = null;
|
||||
try
|
||||
{
|
||||
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (sceneItem is not null)
|
||||
{
|
||||
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
|
||||
newBlob = blob?.ToString();
|
||||
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
|
||||
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* leave newBlob null */ }
|
||||
|
||||
if (string.IsNullOrEmpty(newBlob)) return;
|
||||
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = newBlob;
|
||||
DebugMod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object? TryGet(object dict, MethodInfo getItem, string key)
|
||||
{
|
||||
try { return getItem.Invoke(dict, new object[] { key }); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
|
||||
// path only when the new value differs from the cached field. The
|
||||
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
|
||||
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
|
||||
// save it calls the setter with the OLD blob — a no-op that skips
|
||||
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
|
||||
// pointed at the boot-time bytes, and PostSaveReloading deserializes
|
||||
// those over the live world — wiping any MakerPen object the user
|
||||
// just placed.
|
||||
//
|
||||
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
|
||||
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
|
||||
// after b__4 returns True, even though the SubscriptionUpdateRoom
|
||||
// push already carried Scenes[0].DataBlobName="v11".
|
||||
//
|
||||
// Strategy: intercept the setter. If the incoming value matches the
|
||||
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
|
||||
// newer version (the freshly-pushed one), substitute that into the
|
||||
// value parameter so the real setter sees a transition and fires
|
||||
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
|
||||
// Falls through silently when no override is needed.
|
||||
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
|
||||
{
|
||||
string currentForLog = "<unread>";
|
||||
string sceneForLog = "<unread>";
|
||||
string scenes0ForLog = "<unread>";
|
||||
try
|
||||
{
|
||||
if (__instance is null)
|
||||
{
|
||||
DebugMod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var rpmType = __instance.GetType();
|
||||
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (blobNameProp is null)
|
||||
{
|
||||
DebugMod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
|
||||
return;
|
||||
}
|
||||
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
|
||||
|
||||
var roomsType = FindGameType("RecNet.Rooms");
|
||||
if (roomsType is null)
|
||||
{
|
||||
DebugMod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var localScene = roomsType.GetProperty("LocalRoomScene",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localScene is not null)
|
||||
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
|
||||
|
||||
var localDetails = roomsType.GetProperty("LocalRoomDetails",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localDetails is not null)
|
||||
{
|
||||
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
|
||||
if (scenes is not null)
|
||||
{
|
||||
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
var first = indexer?.Invoke(scenes, new object[] { 0 });
|
||||
if (first is not null)
|
||||
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional trace of EVERYTHING we read so we can see in
|
||||
// the log exactly why we did or didn't override.
|
||||
DebugMod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
|
||||
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
|
||||
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
|
||||
|
||||
// Pick a candidate "newer" blob name. Priority order:
|
||||
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
|
||||
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
|
||||
// cache gate drops the push).
|
||||
// 2. LocalRoomScene.DataBlobName.
|
||||
// 3. Scenes[0].DataBlobName.
|
||||
string? pushedBlob = s_lastPushedBlobName;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
|
||||
|
||||
if (string.IsNullOrEmpty(pushedBlob)) return;
|
||||
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
|
||||
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
|
||||
|
||||
DebugMod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
|
||||
value = pushedBlob;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
|
||||
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
|
||||
}
|
||||
}
|
||||
|
||||
private static Type? FindGameType(string name) => DebugMod.ResolveType(name);
|
||||
}
|
||||
internal static class QuitTracePatches
|
||||
{
|
||||
public static bool ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit-trace] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit-trace] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
if (exitCode == 1550502249 || exitCode == 533223478)
|
||||
{
|
||||
Write($"[quit-blocked] UnityEngine.Application.Quit fatal client exit {exitCode}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP}, {EPAIMCEIMPA})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ApplicationWantsToQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)} wants-to-quit handler entered");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void CreateExitTask_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)} creating exit task");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
private static string FormatOriginal(MethodBase method)
|
||||
{
|
||||
return $"{method.DeclaringType?.FullName}.{method.Name}";
|
||||
}
|
||||
|
||||
private static void Write(string message)
|
||||
{
|
||||
try { DebugMod.Log.Msg(message); } catch { }
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[quit-trace-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[quit-trace-stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
internal static class DiagnosticPatches
|
||||
{
|
||||
public static void Write(string message)
|
||||
{
|
||||
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
|
||||
try { DebugMod.Log.Msg(line); } catch { }
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(DebugMod.DiagnosticsLogPath))
|
||||
File.AppendAllText(DebugMod.DiagnosticsLogPath, line + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static bool ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void NamedMethod_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP}, {EPAIMCEIMPA})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
internal static class ChatPatches
|
||||
{
|
||||
private static bool s_logged;
|
||||
|
||||
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
|
||||
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
|
||||
// before raising the in-room Photon chat RPC; if it returns false
|
||||
// the coroutine bails silently and the sender doesn't even see
|
||||
// their own message echo.
|
||||
public static void CanLocalPlayerChat_Postfix(ref bool __result)
|
||||
{
|
||||
if (!__result && !s_logged)
|
||||
{
|
||||
s_logged = true;
|
||||
DebugMod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
|
||||
}
|
||||
__result = true;
|
||||
}
|
||||
|
||||
// Skip the original. Returning false from a Harmony prefix stops
|
||||
// the underlying call.
|
||||
public static bool RemoveInvalidCharactersFromMessage_Prefix()
|
||||
{
|
||||
DebugMod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void SendChatEmote_Prefix(string message)
|
||||
{
|
||||
DebugMod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
|
||||
}
|
||||
|
||||
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
|
||||
{
|
||||
DebugMod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
|
||||
}
|
||||
|
||||
public static void RpcChatEmote_Prefix(string message)
|
||||
{
|
||||
DebugMod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
|
||||
}
|
||||
}
|
||||
internal static class RegistrationPatches
|
||||
{
|
||||
private static bool s_loggedFullyReg;
|
||||
|
||||
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
|
||||
// (does NOT modify) the return value. False ⇒ the dorm raises the
|
||||
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
|
||||
// stays readable (it's polled from several call sites every boot).
|
||||
public static void IsFullyRegistered_Postfix(bool __result)
|
||||
{
|
||||
if (!s_loggedFullyReg)
|
||||
{
|
||||
s_loggedFullyReg = true;
|
||||
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
|
||||
}
|
||||
}
|
||||
|
||||
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
|
||||
// the dialog-response handler (a dialog was shown). Names reach the
|
||||
// prefix via __originalMethod so we can tell which branch ran.
|
||||
public static void PromptLambda_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
|
||||
}
|
||||
|
||||
// ConfirmUIDialog button handlers. If one of these fires when the user
|
||||
// taps a dialog button but the modal stays open, the break is in the
|
||||
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
|
||||
// routing. "Okay" on the screenshot is Button_Affirmative.
|
||||
public static bool ButtonAffirmative_Prefix(object __instance)
|
||||
{
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
|
||||
|
||||
if (!IsRegistrationWarningDialog(__instance))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryResolveAffirmativeWithoutClose(__instance))
|
||||
{
|
||||
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
|
||||
return false;
|
||||
}
|
||||
|
||||
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ButtonNegative_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
|
||||
public static void ButtonNotNow_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
|
||||
public static void ButtonCancel_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
|
||||
|
||||
private static bool IsRegistrationWarningDialog(object instance)
|
||||
{
|
||||
var title = ReadTextField(instance, "titleText");
|
||||
var body = ReadTextField(instance, "bodyText");
|
||||
|
||||
return Contains(title, "Change Username")
|
||||
&& Contains(body, "permanently lost")
|
||||
&& Contains(body, "password");
|
||||
}
|
||||
|
||||
private static bool TryResolveAffirmativeWithoutClose(object instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
var respond = AccessTools.Method(instance.GetType(), "Respond");
|
||||
if (respond is null)
|
||||
{
|
||||
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
|
||||
}
|
||||
|
||||
if (respond is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameters = respond.GetParameters();
|
||||
if (parameters.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
|
||||
respond.Invoke(instance, new[] { response, (object)false });
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static object CreateAffirmativeResponse(Type responseType)
|
||||
{
|
||||
if (responseType.IsEnum)
|
||||
{
|
||||
return Enum.ToObject(responseType, 1);
|
||||
}
|
||||
|
||||
return Convert.ChangeType(1, responseType);
|
||||
}
|
||||
|
||||
private static string? ReadTextField(object instance, string fieldName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var field = AccessTools.Field(instance.GetType(), fieldName);
|
||||
var textObject = field?.GetValue(instance);
|
||||
if (textObject is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var property = AccessTools.Property(textObject.GetType(), "text");
|
||||
if (property?.GetValue(textObject) is string text)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
var getter = AccessTools.Method(textObject.GetType(), "get_text");
|
||||
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string fragment)
|
||||
{
|
||||
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
internal static class JoinPatches
|
||||
{
|
||||
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
|
||||
// "Dorm Room" button. If this never fires, the button's UnityEvent
|
||||
// binding is the problem, not the join chain.
|
||||
public static void ButtonDormRoom_Prefix()
|
||||
{
|
||||
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
|
||||
}
|
||||
|
||||
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
|
||||
// values here: Harmony's `object[] __args` forces a trampoline that
|
||||
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
|
||||
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
|
||||
// when the boot-time dorm auto-join first calls this method. Logging
|
||||
// the overload's parameter *types* (pure reflection metadata, no value
|
||||
// boxing) is enough to tell which overload fired — the entry overload
|
||||
// (String,…) is the "DormRoom" press; the others are inner legs.
|
||||
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
|
||||
}
|
||||
|
||||
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
|
||||
// resolver that feeds the join. __0 is the single first param: a room
|
||||
// NAME string (String overload) or room ID (Int64 overload). Reading
|
||||
// it via `object __0` is safe — it's a string/long, not one of the
|
||||
// obfuscated ref-types that crashed `object[] __args`. This is the
|
||||
// value we need to compare boot (works) vs button (fails).
|
||||
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
|
||||
{
|
||||
var arg = __0?.ToString() ?? "<null>";
|
||||
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
|
||||
}
|
||||
|
||||
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
|
||||
// b__165_1 is the reject handler that raises the "An error occured /
|
||||
// contact recroom.happyfox.com" toast — so if THIS name appears, the
|
||||
// join failed; the success legs (other b__*) firing means it advanced.
|
||||
// (Same __args/0x80131506 constraint as above — name only.)
|
||||
public static void JoinCallback_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||
}
|
||||
|
||||
// Dedicated capture for the reject handler b__165_1(string error) —
|
||||
// the answer to "why did the join fail". Single string param, so
|
||||
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
|
||||
// is the string that becomes the happyfox toast.
|
||||
public static void JoinError_Prefix(object __0)
|
||||
{
|
||||
var err = __0?.ToString() ?? "<null>";
|
||||
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
|
||||
}
|
||||
|
||||
// Parameter-type list from reflection metadata only — never reads
|
||||
// instance values, so it can't trigger the MonoMod trampoline crash.
|
||||
private static string Sig(MethodBase m)
|
||||
{
|
||||
var ps = m.GetParameters();
|
||||
var names = new List<string>(ps.Length);
|
||||
foreach (var p in ps) names.Add(p.ParameterType.Name);
|
||||
return $"{ps.Length} args: {string.Join(",", names)}";
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
|
||||
|
||||
The shipping ClientMod carries ONLY the patches needed to connect and
|
||||
survive anti-cheat. ALL of the verbose diagnostic/tracing code (HTTP
|
||||
tracing, join/quit/studio/room-load traces, the process-exception sink,
|
||||
auth-token tracing, the registration-dialog trace, the in-game
|
||||
DebugConsole enable path) lives in THIS mod. Drop this dll into the
|
||||
Mods folder next to DorkNet.ClientMod.dll to debug; remove it to ship a
|
||||
quiet, minimal client.
|
||||
|
||||
Fully self-contained: its own Harmony instance + its own copy of the
|
||||
reflection/patch helpers, so it never couples to ClientMod at load time.
|
||||
Debug knobs are read from the same UserData\dorknet-clientmod.json.
|
||||
|
||||
Build: dotnet build DorkNet.DebugMod/DorkNet.DebugMod.csproj -c Release
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.DebugMod</RootNamespace>
|
||||
<AssemblyName>DorkNet.DebugMod</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the MelonLoader the ClientMod build already downloads. -->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader\MelonLoader\net6</MelonLoaderDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,18 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
|
||||
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,351 +0,0 @@
|
||||
using DorkNet.Contracts;
|
||||
using DorkNet.ServiceDefaults;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Configuration.AddJsonFile("appsettings.Local.json",
|
||||
optional: true, reloadOnChange: true);
|
||||
|
||||
builder.AddDorkNetServiceDefaults(ServiceNames.Gateway);
|
||||
builder.Services.Configure<DorkNetServiceMapOptions>(
|
||||
builder.Configuration.GetSection("DorkNet:Services"));
|
||||
var serviceMap = builder.Configuration
|
||||
.GetSection("DorkNet:Services")
|
||||
.Get<DorkNetServiceMapOptions>() ?? new DorkNetServiceMapOptions();
|
||||
var apexDomain = builder.Configuration["Domain:Apex"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
||||
?? "localhost";
|
||||
builder.Services
|
||||
.AddReverseProxy()
|
||||
.LoadFromMemory(
|
||||
BuildRoutes(apexDomain, serviceMap),
|
||||
BuildClusters(serviceMap));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapDorkNetServiceDefaults();
|
||||
|
||||
app.MapGet("/internal/services", (IOptions<DorkNetServiceMapOptions> options) =>
|
||||
Results.Ok(options.Value.Endpoints()));
|
||||
app.MapGet("/internal/routes", () =>
|
||||
Results.Ok(new
|
||||
{
|
||||
ApexDomain = apexDomain,
|
||||
Routes = BuildRoutes(apexDomain, serviceMap).Select(r => new
|
||||
{
|
||||
r.RouteId,
|
||||
r.ClusterId,
|
||||
r.Match.Hosts,
|
||||
r.Match.Path,
|
||||
r.Order,
|
||||
}),
|
||||
}));
|
||||
|
||||
app.MapGet("/internal/services/health", async (
|
||||
IOptions<DorkNetServiceMapOptions> options,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var client = httpClientFactory.CreateClient();
|
||||
var probes = new List<ServiceProbeResponse>();
|
||||
|
||||
foreach (var endpoint in options.Value.Endpoints())
|
||||
{
|
||||
probes.Add(await ProbeServiceAsync(client, endpoint, ct));
|
||||
}
|
||||
|
||||
return Results.Ok(probes);
|
||||
});
|
||||
|
||||
app.MapReverseProxy();
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task<ServiceProbeResponse> ProbeServiceAsync(
|
||||
HttpClient client,
|
||||
DorkNetServiceEndpoint endpoint,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var checkedAt = DateTimeOffset.UtcNow;
|
||||
var baseUrl = endpoint.BaseUrl.TrimEnd('/');
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.GetAsync($"{baseUrl}/internal/healthz", ct);
|
||||
return new ServiceProbeResponse(
|
||||
endpoint.Name,
|
||||
endpoint.BaseUrl,
|
||||
response.IsSuccessStatusCode ? "ok" : "unhealthy",
|
||||
(int)response.StatusCode,
|
||||
null,
|
||||
checkedAt);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
return new ServiceProbeResponse(
|
||||
endpoint.Name,
|
||||
endpoint.BaseUrl,
|
||||
"unreachable",
|
||||
null,
|
||||
ex.Message,
|
||||
checkedAt);
|
||||
}
|
||||
}
|
||||
|
||||
static IReadOnlyList<RouteConfig> BuildRoutes(
|
||||
string apexDomain,
|
||||
DorkNetServiceMapOptions services)
|
||||
{
|
||||
var routes = new List<RouteConfig>();
|
||||
var order = 0;
|
||||
var webGroup = DorkNetRouteOwnership.RouteGroups
|
||||
.First(group => group.ServiceName == ServiceNames.Web);
|
||||
|
||||
AddHostRoute(
|
||||
routes,
|
||||
ref order,
|
||||
webGroup.ServiceName,
|
||||
BaseUrlFor(services, webGroup.ServiceName),
|
||||
"web-admin-host",
|
||||
[SubdomainHost("admin", apexDomain)]);
|
||||
|
||||
AddHostRoute(
|
||||
routes,
|
||||
ref order,
|
||||
webGroup.ServiceName,
|
||||
BaseUrlFor(services, webGroup.ServiceName),
|
||||
"web-feed-host",
|
||||
[SubdomainHost("feed", apexDomain)]);
|
||||
|
||||
AddHostPathRoutes(
|
||||
routes,
|
||||
ref order,
|
||||
webGroup.ServiceName,
|
||||
BaseUrlFor(services, webGroup.ServiceName),
|
||||
WebBrowserHosts(apexDomain),
|
||||
[
|
||||
"/admin",
|
||||
"/assets",
|
||||
"/brand",
|
||||
"/feed",
|
||||
"/join",
|
||||
"/p",
|
||||
"/photo",
|
||||
"/players",
|
||||
"/rooms",
|
||||
"/u",
|
||||
]);
|
||||
|
||||
foreach (var group in DorkNetRouteOwnership.RouteGroups.Where(group => group.ServiceName != ServiceNames.Web))
|
||||
{
|
||||
AddHostRoute(
|
||||
routes,
|
||||
ref order,
|
||||
group.ServiceName,
|
||||
BaseUrlFor(services, group.ServiceName),
|
||||
$"{group.ServiceName}-hosts",
|
||||
group.HostSubdomains.Select(subdomain => SubdomainHost(subdomain, apexDomain)).ToArray());
|
||||
}
|
||||
|
||||
foreach (var group in DorkNetRouteOwnership.RouteGroups)
|
||||
{
|
||||
AddPathRoutes(
|
||||
routes,
|
||||
ref order,
|
||||
group.ServiceName,
|
||||
BaseUrlFor(services, group.ServiceName),
|
||||
group.PathPrefixes);
|
||||
}
|
||||
|
||||
AddHostRoute(
|
||||
routes,
|
||||
ref order,
|
||||
webGroup.ServiceName,
|
||||
BaseUrlFor(services, webGroup.ServiceName),
|
||||
"web-hosts",
|
||||
BuildWebHosts(webGroup, apexDomain));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(services.Monolith))
|
||||
{
|
||||
routes.Add(Route(ServiceNames.Monolith, "monolith-fallback", "/{**catch-all}", order++));
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
static IReadOnlyList<ClusterConfig> BuildClusters(DorkNetServiceMapOptions services)
|
||||
{
|
||||
var clusters = new List<ClusterConfig>();
|
||||
foreach (var endpoint in services.Endpoints())
|
||||
{
|
||||
AddCluster(clusters, endpoint.Name, endpoint.BaseUrl);
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
static string[] WebBrowserHosts(string apexDomain)
|
||||
{
|
||||
return [apexDomain, SubdomainHost("www", apexDomain)];
|
||||
}
|
||||
|
||||
static void AddHostRoute(
|
||||
List<RouteConfig> routes,
|
||||
ref int order,
|
||||
string serviceName,
|
||||
string baseUrl,
|
||||
string routeId,
|
||||
string[] hosts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
routes.Add(Route(
|
||||
serviceName,
|
||||
routeId,
|
||||
"/{**catch-all}",
|
||||
order++,
|
||||
hosts));
|
||||
}
|
||||
|
||||
static void AddPathRoutes(
|
||||
List<RouteConfig> routes,
|
||||
ref int order,
|
||||
string serviceName,
|
||||
string baseUrl,
|
||||
IEnumerable<string> paths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var routeId = $"{serviceName}-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
|
||||
var exactPath = NormalizeRoutePrefix(path);
|
||||
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++));
|
||||
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++));
|
||||
}
|
||||
}
|
||||
|
||||
static void AddHostPathRoutes(
|
||||
List<RouteConfig> routes,
|
||||
ref int order,
|
||||
string serviceName,
|
||||
string baseUrl,
|
||||
string[] hosts,
|
||||
IEnumerable<string> paths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var routeId = $"{serviceName}-browser-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
|
||||
var exactPath = NormalizeRoutePrefix(path);
|
||||
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++, hosts));
|
||||
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++, hosts));
|
||||
}
|
||||
}
|
||||
|
||||
static string BaseUrlFor(DorkNetServiceMapOptions services, string serviceName)
|
||||
{
|
||||
return serviceName switch
|
||||
{
|
||||
ServiceNames.Identity => services.Identity,
|
||||
ServiceNames.Rooms => services.Rooms,
|
||||
ServiceNames.Notify => services.Notify,
|
||||
ServiceNames.Content => services.Content,
|
||||
ServiceNames.Social => services.Social,
|
||||
ServiceNames.Commerce => services.Commerce,
|
||||
ServiceNames.Platform => services.Platform,
|
||||
ServiceNames.Moderation => services.Moderation,
|
||||
ServiceNames.Web => services.Web,
|
||||
ServiceNames.Monolith => services.Monolith,
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
static string[] BuildWebHosts(DorkNetRouteGroup group, string apexDomain)
|
||||
{
|
||||
var hosts = new List<string>();
|
||||
if (group.IncludesApexHost)
|
||||
{
|
||||
hosts.Add(apexDomain);
|
||||
}
|
||||
|
||||
hosts.AddRange(group.HostSubdomains
|
||||
.Where(subdomain => !string.Equals(subdomain, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(subdomain => SubdomainHost(subdomain, apexDomain)));
|
||||
|
||||
return hosts.ToArray();
|
||||
}
|
||||
|
||||
static string SubdomainHost(string subdomain, string apexDomain)
|
||||
{
|
||||
return $"{subdomain}.{apexDomain}";
|
||||
}
|
||||
|
||||
static string NormalizeRoutePrefix(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || path == "/")
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
return (path[0] == '/' ? path : "/" + path).TrimEnd('/');
|
||||
}
|
||||
|
||||
static RouteConfig Route(
|
||||
string clusterId,
|
||||
string routeId,
|
||||
string path,
|
||||
int order,
|
||||
string[]? hosts = null)
|
||||
{
|
||||
return new RouteConfig
|
||||
{
|
||||
RouteId = routeId,
|
||||
ClusterId = clusterId,
|
||||
Order = order,
|
||||
Match = new RouteMatch
|
||||
{
|
||||
Hosts = hosts,
|
||||
Path = path,
|
||||
},
|
||||
Transforms =
|
||||
[
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["RequestHeaderOriginalHost"] = "true",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static void AddCluster(
|
||||
List<ClusterConfig> clusters,
|
||||
string serviceName,
|
||||
string baseUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var address = baseUrl.EndsWith('/') ? baseUrl : baseUrl + "/";
|
||||
clusters.Add(new ClusterConfig
|
||||
{
|
||||
ClusterId = serviceName,
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
["primary"] = new() { Address = address },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
},
|
||||
"DorkNet": {
|
||||
"Services": {
|
||||
"Identity": "http://localhost:8081",
|
||||
"Rooms": "http://localhost:8082",
|
||||
"Notify": "http://localhost:8083",
|
||||
"Content": "http://localhost:8084",
|
||||
"Social": "http://localhost:8085",
|
||||
"Commerce": "http://localhost:8086",
|
||||
"Platform": "http://localhost:8087",
|
||||
"Moderation": "http://localhost:8088",
|
||||
"Web": "http://localhost:8089",
|
||||
"Monolith": "http://localhost:8090"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,13 +81,6 @@ public class RecNetAccount
|
||||
// Likewise.
|
||||
[JsonPropertyName("hasBirthday")]
|
||||
public bool HasBirthday { get; set; } = true;
|
||||
|
||||
// 2020.12 Account.Deserialize reads this key (verified via Cpp2IL ISIL
|
||||
// dump of CCEOLAOLEKJ.PPGFHEDFBEA — the obfuscated Account class). The
|
||||
// 2020.03 client doesn't read it, but emitting it is harmless there.
|
||||
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Standalone MelonLoader IL2CPP mod whose ONLY job is to dump every
|
||||
Google.Protobuf FileDescriptor that the running client has loaded.
|
||||
|
||||
It carries no Harmony patches, so it is safe to drop onto ANY Rec Room
|
||||
build (2020, 2023, …) without the version-specific ClientMod patches
|
||||
misfiring. Everything game-side is reached through plain
|
||||
System.Reflection over the Il2CppInterop proxy assemblies, so no game
|
||||
DLLs are referenced at compile time (they would be derivative works).
|
||||
|
||||
Build (desktop net6 MelonLoader, same pin as the ClientMod):
|
||||
|
||||
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||
|
||||
Drop bin\Release\DorkNet.ProtoDumper.dll into <RecRoom>\Mods\ on a
|
||||
MelonLoader-patched build and launch. Output lands in
|
||||
<RecRoom>\DorkNet-ProtoDump\.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.ProtoDumper</RootNamespace>
|
||||
<AssemblyName>DorkNet.ProtoDumper</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the MelonLoader DLLs the ClientMod project already downloads. -->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
|
||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
|
||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Unzip SourceFiles="$(MelonLoaderZip)"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
OverwriteReadOnlyFiles="true" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,378 +0,0 @@
|
||||
// DorkNet ProtoDumper — runtime Google.Protobuf schema extractor.
|
||||
//
|
||||
// Rec Room compiles its room-save / creation / circuits data model from
|
||||
// .proto files into Google.Protobuf C# classes. In the shipped IL2CPP build
|
||||
// those classes are name-obfuscated and the descriptor base64 is split into
|
||||
// scattered string-literal chunks, so static reconstruction from
|
||||
// global-metadata.dat is unreliable.
|
||||
//
|
||||
// At RUNTIME every FileDescriptor still carries its original, un-obfuscated
|
||||
// FileDescriptorProto (message names, field names, numbers, types intact).
|
||||
// The catch: these descriptors are built LAZILY and in a specific dependency
|
||||
// order by the game. Force-building them ourselves (by reading a message
|
||||
// type's static descriptor property out of order) throws inside the type's
|
||||
// static constructor and the failure is cached — poisoning the type. So we do
|
||||
// NOT enumerate types. Instead we Harmony-postfix the one library method every
|
||||
// generated cctor funnels through — Google.Protobuf.Reflection.FileDescriptor
|
||||
// .BuildFrom / .FromGeneratedCode — and capture each descriptor as the game
|
||||
// builds it. Completely passive: just play the client through the dorm, a
|
||||
// created room, the Maker Pen and a circuits board, and every descriptor that
|
||||
// loads is recorded.
|
||||
//
|
||||
// Output (under <RecRoom>\DorkNet-ProtoDump\):
|
||||
// manifest.tsv — one "<proto path>\t<base64>" line per file
|
||||
// files\<sanitised>.b64 — same base64, one file each
|
||||
// dump.log — capture log
|
||||
//
|
||||
// Decode offline with decode_dump.py (see README.md).
|
||||
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ProtoDumper.ProtoDumper), "DorkNet ProtoDumper", "2.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.ProtoDumper;
|
||||
|
||||
public class ProtoDumper : MelonMod
|
||||
{
|
||||
private static MelonLogger.Instance? _log;
|
||||
|
||||
// proto file name -> descriptor base64 (accumulated as the game builds them)
|
||||
private static readonly Dictionary<string, string> Files = new(StringComparer.Ordinal);
|
||||
private static readonly object Gate = new();
|
||||
private static string _outDir = "";
|
||||
private static string _filesDir = "";
|
||||
private static volatile bool _dirty;
|
||||
private int _frame;
|
||||
|
||||
// Interception heartbeat: how many times any hooked method actually ran.
|
||||
// Lets us distinguish "hook never fires (inlined/wrong target)" from
|
||||
// "content that builds protos hasn't loaded yet".
|
||||
private static long _fireCount;
|
||||
private static bool _firstFireLogged;
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
_log = LoggerInstance;
|
||||
try
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory ?? Directory.GetCurrentDirectory();
|
||||
_outDir = Path.Combine(baseDir, "DorkNet-ProtoDump");
|
||||
_filesDir = Path.Combine(_outDir, "files");
|
||||
Directory.CreateDirectory(_filesDir);
|
||||
_log.Msg($"ProtoDumper ready. Output dir: {_outDir}");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper init failed: {e}"); }
|
||||
|
||||
InstallHooks();
|
||||
}
|
||||
|
||||
private void InstallHooks()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Google.Protobuf library type — NOT obfuscated (it's the library,
|
||||
// not game code), reached by its original name via every loaded
|
||||
// assembly so we never reference the interop DLL at compile time.
|
||||
Type? fd = ResolveType("Google.Protobuf.Reflection.FileDescriptor");
|
||||
if (fd == null)
|
||||
{
|
||||
_log?.Error("ProtoDumper: FileDescriptor type not found — cannot hook.");
|
||||
return;
|
||||
}
|
||||
|
||||
var postfix = new HarmonyMethod(
|
||||
typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix),
|
||||
BindingFlags.Public | BindingFlags.Static));
|
||||
var ctorPostfix = new HarmonyMethod(
|
||||
typeof(ProtoDumper).GetMethod(nameof(Ctor_Postfix),
|
||||
BindingFlags.Public | BindingFlags.Static));
|
||||
|
||||
int patched = 0;
|
||||
|
||||
// PRIMARY hook: the 6-arg instance constructor. Every FileDescriptor
|
||||
// is built via `newobj` through this ctor, which is NOT an inlining
|
||||
// candidate the way the small static BuildFrom/FromGeneratedCode
|
||||
// helpers are (those get AOT-inlined into each generated cctor, so a
|
||||
// Harmony patch on the managed wrapper never fires — the real work
|
||||
// runs as inlined native code). The ctor stores SerializedData /
|
||||
// Proto / Name before it returns, so __instance carries the full
|
||||
// descriptor bytes.
|
||||
foreach (ConstructorInfo c in fd.GetConstructors(
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
var ps = c.GetParameters();
|
||||
// Skip the Il2CppInterop IntPtr wrapper ctor (single IntPtr arg).
|
||||
if (ps.Length == 1 && ps[0].ParameterType == typeof(IntPtr)) continue;
|
||||
try { HarmonyInstance.Patch(c, postfix: ctorPostfix); patched++; _log?.Msg($"ProtoDumper: hooked .ctor({ps.Length} args)"); }
|
||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch .ctor({ps.Length}) failed: {e.Message}"); }
|
||||
}
|
||||
|
||||
// SECONDARY hooks (kept as a belt-and-braces net in case the ctor is
|
||||
// itself inlined on some build): BuildFrom / FromGeneratedCode.
|
||||
foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" })
|
||||
{
|
||||
foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != name) continue;
|
||||
try { HarmonyInstance.Patch(m, postfix: postfix); patched++; _log?.Msg($"ProtoDumper: hooked {name}({m.GetParameters().Length} args)"); }
|
||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// STRONGEST hook: DescriptorPool.AddSymbol(IDescriptor). Called once
|
||||
// per message/enum/service symbol while a descriptor is built. Unlike
|
||||
// the tiny BuildFrom/FromGeneratedCode/ctor helpers, AddSymbol does
|
||||
// real work (name validation + dictionary insert + duplicate-throw),
|
||||
// so the AOT compiler does NOT inline it away — the managed patch
|
||||
// actually fires. From the symbol we walk `.File` to the owning
|
||||
// FileDescriptor and collect its serialized bytes. We also stash the
|
||||
// pool so ScanPools() can later enumerate everything it holds.
|
||||
Type? pool = ResolveType("Google.Protobuf.Reflection.DescriptorPool");
|
||||
if (pool != null)
|
||||
{
|
||||
var symPostfix = new HarmonyMethod(
|
||||
typeof(ProtoDumper).GetMethod(nameof(AddSymbol_Postfix),
|
||||
BindingFlags.Public | BindingFlags.Static));
|
||||
foreach (MethodInfo m in pool.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
if (m.Name != "AddSymbol" && m.Name != "AddPackage") continue;
|
||||
try { HarmonyInstance.Patch(m, postfix: symPostfix); patched++; _log?.Msg($"ProtoDumper: hooked DescriptorPool.{m.Name}({m.GetParameters().Length} args)"); }
|
||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch DescriptorPool.{m.Name} failed: {e.Message}"); }
|
||||
}
|
||||
}
|
||||
else _log?.Warning("ProtoDumper: DescriptorPool type not found — AddSymbol hook skipped.");
|
||||
|
||||
_log?.Msg($"ProtoDumper: installed {patched} descriptor hooks. Play through your content; descriptors are captured as they load. Watch for 'FIRST DESCRIPTOR CAPTURED'.");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper hook install error: {e}"); }
|
||||
}
|
||||
|
||||
// Resolve a type by its ORIGINAL (game-side) name, accounting for the
|
||||
// Il2Cpp prefix Il2CppInterop puts on the namespace (so
|
||||
// "Google.Protobuf.Reflection.FileDescriptor" ->
|
||||
// "Il2CppGoogle.Protobuf.Reflection.FileDescriptor").
|
||||
private static Type? ResolveType(string name)
|
||||
{
|
||||
foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, false) ?? asm.GetType("Il2Cpp" + name, false);
|
||||
if (t != null) return t;
|
||||
int dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var ns = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), false);
|
||||
if (ns != null) return ns;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Harmony postfix on FileDescriptor.BuildFrom / FromGeneratedCode.
|
||||
// __result is the freshly-built FileDescriptor (Il2Cpp proxy, boxed as
|
||||
// object — same pattern the ClientMod uses for il2cpp reference returns).
|
||||
public static void Captured_Postfix(object? __result)
|
||||
{
|
||||
Fired();
|
||||
if (__result == null) return;
|
||||
try { CollectFile(__result); }
|
||||
catch { /* never let our hook disturb the game */ }
|
||||
}
|
||||
|
||||
// Postfix on the FileDescriptor instance ctor — __instance is the freshly
|
||||
// constructed descriptor (SerializedData/Proto/Name already set).
|
||||
public static void Ctor_Postfix(object? __instance)
|
||||
{
|
||||
Fired();
|
||||
if (__instance == null) return;
|
||||
try { CollectFile(__instance); }
|
||||
catch { /* never let our hook disturb the game */ }
|
||||
}
|
||||
|
||||
// Live DescriptorPool references seen via AddSymbol, kept so ScanPools() can
|
||||
// enumerate every descriptor a pool holds (already-built → no force-build).
|
||||
private static readonly List<object> Pools = new();
|
||||
|
||||
// Postfix on DescriptorPool.AddSymbol / AddPackage. __instance is the pool,
|
||||
// __0 is the IDescriptor symbol being registered. Walk symbol.File to the
|
||||
// owning FileDescriptor and collect it.
|
||||
public static void AddSymbol_Postfix(object? __instance, object? __0)
|
||||
{
|
||||
Fired();
|
||||
try
|
||||
{
|
||||
if (__instance != null)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
bool known = false;
|
||||
foreach (var p in Pools) if (ReferenceEquals(p, __instance)) { known = true; break; }
|
||||
if (!known) Pools.Add(__instance);
|
||||
}
|
||||
}
|
||||
if (__0 == null) return;
|
||||
object? file = GetProp(__0, "File");
|
||||
if (file != null) CollectFile(file);
|
||||
}
|
||||
catch { /* never let our hook disturb the game */ }
|
||||
}
|
||||
|
||||
// Interception heartbeat — proves a hooked method actually ran. Logged once
|
||||
// on first fire, so "no output" can be diagnosed as inlining (never fires)
|
||||
// vs. content-not-loaded (fires but no proto-building content yet).
|
||||
private static void Fired()
|
||||
{
|
||||
long n = System.Threading.Interlocked.Increment(ref _fireCount);
|
||||
if (!_firstFireLogged)
|
||||
{
|
||||
_firstFireLogged = true;
|
||||
_log?.Msg("ProtoDumper: FIRST DESCRIPTOR CAPTURED — interception is live.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectFile(object file)
|
||||
{
|
||||
string? name = GetProp(file, "Name") as string;
|
||||
if (string.IsNullOrEmpty(name)) return;
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (Files.ContainsKey(name!)) return;
|
||||
|
||||
string b64 = DescriptorBase64(file);
|
||||
Files[name!] = b64;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
// Capture import dependencies too (closure of well-known + leaf protos).
|
||||
try
|
||||
{
|
||||
object? deps = GetProp(file, "Dependencies");
|
||||
if (deps != null)
|
||||
{
|
||||
var dt = deps.GetType();
|
||||
var countP = dt.GetProperty("Count");
|
||||
var itemM = dt.GetMethod("get_Item", new[] { typeof(int) });
|
||||
if (countP != null && itemM != null)
|
||||
{
|
||||
int n = (int)(countP.GetValue(deps) ?? 0);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
object? dep = itemM.Invoke(deps, new object[] { i });
|
||||
if (dep != null) CollectFile(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string DescriptorBase64(object file)
|
||||
{
|
||||
try
|
||||
{
|
||||
// FileDescriptor exposes no public SerializedData property — only
|
||||
// the Il2CppInterop backing field (a ByteString with the original
|
||||
// descriptor bytes). Fall back to re-serialising the parsed Proto.
|
||||
object? sd = GetProp(file, "_SerializedData_k__BackingField")
|
||||
?? GetProp(file, "SerializedData");
|
||||
if (sd != null)
|
||||
{
|
||||
string b = ByteStringToBase64(sd);
|
||||
if (b.Length > 0) return b;
|
||||
}
|
||||
object? proto = GetProp(file, "Proto");
|
||||
if (proto != null)
|
||||
{
|
||||
MethodInfo? toBs = proto.GetType().GetMethod("ToByteString", Type.EmptyTypes);
|
||||
object? bs = toBs?.Invoke(proto, null);
|
||||
if (bs != null) return ByteStringToBase64(bs);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string ByteStringToBase64(object byteString)
|
||||
{
|
||||
MethodInfo? toB64 = byteString.GetType().GetMethod("ToBase64", Type.EmptyTypes);
|
||||
return toB64?.Invoke(byteString, null) as string ?? "";
|
||||
}
|
||||
|
||||
private static object? GetProp(object obj, string propName)
|
||||
{
|
||||
var p = obj.GetType().GetProperty(propName,
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
return p?.GetValue(obj);
|
||||
}
|
||||
|
||||
private long _lastReportedFire = -1;
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
// Flush to disk shortly after new descriptors are captured (every ~1s).
|
||||
if (++_frame % 60 == 0 && _dirty)
|
||||
Flush();
|
||||
|
||||
// Every ~5s, if the hooks fired since last report, log the running
|
||||
// fire/capture counts — surfaces "fired but 0 files" (empty base64)
|
||||
// separately from "never fired".
|
||||
if (_frame % 300 == 0)
|
||||
{
|
||||
long f = System.Threading.Interlocked.Read(ref _fireCount);
|
||||
if (f != _lastReportedFire)
|
||||
{
|
||||
_lastReportedFire = f;
|
||||
int files; lock (Gate) files = Files.Count;
|
||||
_log?.Msg($"ProtoDumper heartbeat: hook-fires={f}, unique-descriptors={files}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit() => Flush();
|
||||
|
||||
private static void Flush()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (!_dirty) return;
|
||||
_dirty = false;
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int withData = 0;
|
||||
foreach (var kv in Files)
|
||||
{
|
||||
if (kv.Value.Length == 0) continue;
|
||||
withData++;
|
||||
sb.Append(kv.Key).Append('\t').Append(kv.Value).Append('\n');
|
||||
string fp = Path.Combine(_filesDir, Sanitize(kv.Key) + ".b64");
|
||||
if (!File.Exists(fp)) File.WriteAllText(fp, kv.Value);
|
||||
}
|
||||
File.WriteAllText(Path.Combine(_outDir, "manifest.tsv"), sb.ToString());
|
||||
string line = $"captured files={Files.Count} (with-data={withData})";
|
||||
File.AppendAllText(Path.Combine(_outDir, "dump.log"), line + "\n");
|
||||
_log?.Msg($"ProtoDumper: {line} -> {_outDir}");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper flush error: {e}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string protoPath)
|
||||
{
|
||||
var sb = new StringBuilder(protoPath.Length);
|
||||
foreach (char c in protoPath)
|
||||
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.' ? c : '_');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
# DorkNet ProtoDumper
|
||||
|
||||
Runtime extractor for Rec Room's Google.Protobuf schema (room save /
|
||||
creation / circuits data model). Produces clean `.proto` files for any
|
||||
client build — including obfuscated ones like 2023.03.21 where static
|
||||
reconstruction from `global-metadata.dat` is unreliable (the descriptor
|
||||
base64 is split into scattered string-literal chunks whose concat order
|
||||
only lives in IL).
|
||||
|
||||
At runtime every `FileDescriptor` still carries its original,
|
||||
un-obfuscated `FileDescriptorProto` (real message names, field names,
|
||||
numbers and types intact). The catch: Rec Room builds these descriptors
|
||||
**lazily and in a specific dependency order** as content loads. Trying to
|
||||
force-build them by reading a message type's static descriptor out of
|
||||
order throws inside the type's static constructor, and the failure is
|
||||
cached — *poisoning* the type (and crashing the client). So this mod does
|
||||
**not** enumerate types.
|
||||
|
||||
Instead it Harmony-postfixes the one library method every generated cctor
|
||||
funnels through —
|
||||
`Google.Protobuf.Reflection.FileDescriptor.BuildFrom` /
|
||||
`.FromGeneratedCode` — and records each descriptor as the game builds it.
|
||||
Completely passive: **play the client through the content you care about**
|
||||
and every descriptor that loads is captured.
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. **Build the mod**
|
||||
|
||||
```sh
|
||||
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||
```
|
||||
|
||||
Output: `DorkNet.ProtoDumper/bin/Release/DorkNet.ProtoDumper.dll`.
|
||||
|
||||
2. **Run it on a *runnable* 2023.03.21 client (MelonLoader 0.6.6)**
|
||||
|
||||
> The `GameAssembly.dll` + `il2cpp_data`-only copy under
|
||||
> `Recnet-old/dist/RecRoom-2023.03.21-il2cpp/…/staging/` is an
|
||||
> **offline-analysis payload, not a runnable build**. Use a full build
|
||||
> with the exe + UnityPlayer + MelonLoader (e.g. the devdork package).
|
||||
|
||||
- Drop `DorkNet.ProtoDumper.dll` into `<RecRoom>\Mods\`. On launch it
|
||||
installs the descriptor-build hooks (see `dump.log` / MelonLoader
|
||||
console for "installed N descriptor-build hooks").
|
||||
- **Log in and play through the content whose schema you want:** your
|
||||
dorm, a created room, the Maker Pen, and a circuits (CV2) board. Each
|
||||
subsystem builds its protos the first time it loads. Descriptors are
|
||||
captured the instant they build — no scene/timer scanning, nothing is
|
||||
force-built, so the client stays stable.
|
||||
- Output appears under `<RecRoom>\DorkNet-ProtoDump\` and is flushed to
|
||||
disk ~1 s after new descriptors load, and again on quit:
|
||||
- `manifest.tsv` — `<proto path>\t<base64>` per file
|
||||
- `files\*.b64` — same, one file each
|
||||
- `dump.log` — running capture count
|
||||
|
||||
3. **Decode to .proto**
|
||||
|
||||
```sh
|
||||
pip install protobuf
|
||||
python DorkNet.ProtoDumper/decode_dump.py <path>/manifest.tsv proto_out
|
||||
```
|
||||
|
||||
Emits one `.proto` per descriptor under `proto_out/`, plus a combined
|
||||
`proto_out/recroom_2023.proto` (every rec_room/circuits message in one
|
||||
file) for an easy diff against
|
||||
`DorkNet.Server/Protos/recroom_2020.proto`.
|
||||
|
||||
## Notes
|
||||
|
||||
- No game DLLs are referenced at compile time. `FileDescriptor` is the
|
||||
Google.Protobuf *library* type (not obfuscated); it's reached by its
|
||||
original name with the Il2Cpp namespace-prefix fallback, and its members
|
||||
are read via reflection. The same DLL works across builds and stays clean
|
||||
of derivative game code.
|
||||
- The only Harmony patches are the two read-only descriptor-build
|
||||
postfixes above — safe to run alongside (or without) `DorkNet.ClientMod`.
|
||||
- The descriptor base64 is the same `FileDescriptorProto` wire format
|
||||
Google's own tooling emits, so `decode_dump.py` (or `protoc
|
||||
--decode=google.protobuf.FileDescriptorProto descriptor.proto`) reads it
|
||||
directly.
|
||||
@@ -1,165 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode a DorkNet ProtoDumper manifest into .proto text.
|
||||
|
||||
Usage:
|
||||
python decode_dump.py manifest.tsv [out_dir]
|
||||
|
||||
manifest.tsv has one "<proto path>\\t<base64 FileDescriptorProto>" line per
|
||||
file (as written by the ProtoDumper MelonLoader mod). This emits one .proto
|
||||
file per descriptor under out_dir/ (default: ./proto_out), plus a combined
|
||||
recroom_2023.proto with every rec_room/circuits message in one file for an
|
||||
easy diff against DorkNet.Server/Protos/recroom_2020.proto.
|
||||
|
||||
Requires: pip install protobuf
|
||||
"""
|
||||
import sys, os, base64, re
|
||||
from google.protobuf import descriptor_pb2 as D
|
||||
|
||||
FDP = D.FileDescriptorProto
|
||||
|
||||
# proto3 scalar type names keyed by FieldDescriptorProto.Type
|
||||
SCALAR = {
|
||||
1: "double", 2: "float", 3: "int64", 4: "uint64", 5: "int32",
|
||||
6: "fixed64", 7: "fixed32", 8: "bool", 9: "string", 12: "bytes",
|
||||
13: "uint32", 15: "sfixed32", 16: "sfixed64", 17: "sint32", 18: "sint64",
|
||||
}
|
||||
LABEL_REPEATED = 3
|
||||
|
||||
|
||||
def type_name(f):
|
||||
if f.type in (11, 14): # message / enum -> use type_name (strip leading dot)
|
||||
return f.type_name.lstrip(".")
|
||||
return SCALAR.get(f.type, f"type{f.type}")
|
||||
|
||||
|
||||
def emit_enum(e, indent):
|
||||
pad = " " * indent
|
||||
out = [f"{pad}enum {e.name} {{"]
|
||||
for v in e.value:
|
||||
out.append(f"{pad} {v.name} = {v.number};")
|
||||
out.append(f"{pad}}}")
|
||||
return out
|
||||
|
||||
|
||||
def emit_message(m, indent=0):
|
||||
pad = " " * indent
|
||||
out = [f"{pad}message {m.name} {{"]
|
||||
|
||||
# map<> detection: synthetic nested *Entry types with map_entry option
|
||||
map_entries = {}
|
||||
for nt in m.nested_type:
|
||||
if nt.options.map_entry:
|
||||
kt = type_name(nt.field[0])
|
||||
vt = type_name(nt.field[1])
|
||||
map_entries[nt.name] = (kt, vt)
|
||||
|
||||
# which oneofs are real (not proto3 optional synthetic)
|
||||
for nt in m.nested_type:
|
||||
if nt.name in map_entries:
|
||||
continue
|
||||
out += emit_message(nt, indent + 1)
|
||||
for en in m.enum_type:
|
||||
out += emit_enum(en, indent + 1)
|
||||
|
||||
# group fields by oneof
|
||||
printed_oneof = set()
|
||||
for f in m.field:
|
||||
# map field?
|
||||
if f.type == 11 and f.type_name.split(".")[-1] in map_entries:
|
||||
kt, vt = map_entries[f.type_name.split(".")[-1]]
|
||||
out.append(f"{pad} map<{kt}, {vt}> {f.name} = {f.number};")
|
||||
continue
|
||||
|
||||
if f.HasField("oneof_index") and not f.proto3_optional:
|
||||
idx = f.oneof_index
|
||||
if idx not in printed_oneof:
|
||||
printed_oneof.add(idx)
|
||||
out.append(f"{pad} oneof {m.oneof_decl[idx].name} {{")
|
||||
for g in m.field:
|
||||
if g.HasField("oneof_index") and g.oneof_index == idx and not g.proto3_optional:
|
||||
out.append(f"{pad} {type_name(g)} {g.name} = {g.number};")
|
||||
out.append(f"{pad} }}")
|
||||
continue
|
||||
|
||||
label = "repeated " if f.label == LABEL_REPEATED else ""
|
||||
opt = "optional " if f.proto3_optional else ""
|
||||
out.append(f"{pad} {label}{opt}{type_name(f)} {f.name} = {f.number};")
|
||||
|
||||
out.append(f"{pad}}}")
|
||||
return out
|
||||
|
||||
|
||||
def render_file(fdp):
|
||||
out = []
|
||||
syntax = fdp.syntax or "proto2"
|
||||
out.append(f'syntax = "{syntax}";')
|
||||
out.append("")
|
||||
if fdp.package:
|
||||
out.append(f"package {fdp.package};")
|
||||
for dep in fdp.dependency:
|
||||
out.append(f'import "{dep}";')
|
||||
if fdp.package or fdp.dependency:
|
||||
out.append("")
|
||||
for en in fdp.enum_type:
|
||||
out += emit_enum(en, 0)
|
||||
out.append("")
|
||||
for m in fdp.message_type:
|
||||
out += emit_message(m, 0)
|
||||
out.append("")
|
||||
return "\n".join(out).rstrip() + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__); sys.exit(1)
|
||||
manifest = sys.argv[1]
|
||||
out_dir = sys.argv[2] if len(sys.argv) > 2 else "proto_out"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
files = []
|
||||
with open(manifest, "r", encoding="utf-8") as fh:
|
||||
for ln in fh:
|
||||
ln = ln.rstrip("\n")
|
||||
if not ln or "\t" not in ln:
|
||||
continue
|
||||
name, b64 = ln.split("\t", 1)
|
||||
try:
|
||||
raw = base64.b64decode(b64)
|
||||
fdp = FDP(); fdp.ParseFromString(raw)
|
||||
except Exception as e:
|
||||
print(f" ! failed to parse {name}: {e}")
|
||||
continue
|
||||
files.append((name, fdp))
|
||||
|
||||
print(f"parsed {len(files)} descriptors")
|
||||
|
||||
# per-file .proto
|
||||
for name, fdp in files:
|
||||
safe = re.sub(r"[^A-Za-z0-9_.\-]", "_", name)
|
||||
with open(os.path.join(out_dir, safe), "w", encoding="utf-8") as w:
|
||||
w.write(render_file(fdp))
|
||||
|
||||
# combined rec_room/circuits view (skip google/* well-known imports)
|
||||
rr = [ (n,f) for n,f in files if not n.startswith("google/") ]
|
||||
seen = set(); combo = ['syntax = "proto3";', "", "package recroom;", ""]
|
||||
total_msgs = 0
|
||||
for name, fdp in sorted(rr, key=lambda x: x[0]):
|
||||
combo.append(f"// ===== {name} (package {fdp.package or '-'}) =====")
|
||||
for m in fdp.message_type:
|
||||
if m.name in seen:
|
||||
continue
|
||||
seen.add(m.name)
|
||||
combo += emit_message(m, 0)
|
||||
combo.append("")
|
||||
total_msgs += 1
|
||||
for en in fdp.enum_type:
|
||||
combo += emit_enum(en, 0)
|
||||
combo.append("")
|
||||
with open(os.path.join(out_dir, "recroom_2023.proto"), "w", encoding="utf-8") as w:
|
||||
w.write("\n".join(combo).rstrip() + "\n")
|
||||
print(f"wrote {out_dir}/recroom_2023.proto with {total_msgs} unique messages "
|
||||
f"from {len(rr)} rec_room/circuits descriptors")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,154 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression: admin "pull a player into an instance" broke because the
|
||||
/// instance id (an int64 well past JS's 2^53 safe-integer range) was
|
||||
/// emitted as a JSON number. The admin SPA's JSON.parse rounded it to the
|
||||
/// nearest double, so the pull URL targeted a non-existent instance and
|
||||
/// the server returned <c>instance_not_active</c>. The fix serializes the
|
||||
/// id as a STRING so the exact digits survive the browser round-trip.
|
||||
/// </summary>
|
||||
public sealed class AdminInstancePullTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
// A dorm-style instance id past 2^53 — the value class that a JS
|
||||
// Number cannot represent exactly.
|
||||
private const long BigInstanceId = 7597291079085522944L;
|
||||
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public AdminInstancePullTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Instances_list_emits_id_as_string_and_pull_with_exact_id_succeeds()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long roomId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
// Promote the caller to admin (AdminOnly reads IsAdmin from DB).
|
||||
var adminRow = await db.Players.FirstAsync(p => p.Id == admin.PlayerId);
|
||||
adminRow.IsAdmin = true;
|
||||
|
||||
roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Put the streamer in a live instance carrying the huge id.
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = BigInstanceId,
|
||||
RoomId = roomId,
|
||||
SubRoomId = 0,
|
||||
PhotonRoomId = "^teststreamerinstance",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(streamer.PlayerId);
|
||||
}
|
||||
|
||||
using var adminClient = ApiClient(admin);
|
||||
adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
|
||||
|
||||
// The list must emit roomInstanceId as a STRING equal to the exact
|
||||
// digits — never a number (which would already be rounded here).
|
||||
var list = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/instances");
|
||||
var row = list.EnumerateArray()
|
||||
.First(r => r.GetProperty("roomInstanceId").GetString() == BigInstanceId.ToString());
|
||||
Assert.Equal(JsonValueKind.String, row.GetProperty("roomInstanceId").ValueKind);
|
||||
|
||||
// Pull the second player in using the EXACT id from the list. This
|
||||
// is what the SPA does; with the string round-trip the digits are
|
||||
// intact and the instance matches (pre-fix: 404 instance_not_active).
|
||||
var idFromList = row.GetProperty("roomInstanceId").GetString()!;
|
||||
using var pull = await adminClient.PostAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/instances/{idFromList}/pull",
|
||||
new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
|
||||
Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, pull.StatusCode);
|
||||
using var body = await JsonDocument.ParseAsync(await pull.Content.ReadAsStreamAsync());
|
||||
Assert.True(body.RootElement.GetProperty("ok").GetBoolean());
|
||||
// The pulled player now shares the streamer's instance.
|
||||
await using var verify = _factory.Services.CreateAsyncScope();
|
||||
var presenceCheck = verify.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
Assert.Equal(BigInstanceId, presenceCheck.GetRoom(pulled.PlayerId)!.RoomInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_with_precision_mangled_id_is_rejected()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long roomId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
|
||||
await db.SaveChangesAsync();
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = BigInstanceId,
|
||||
RoomId = roomId,
|
||||
PhotonRoomId = "^teststreamerinstance2",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(streamer.PlayerId);
|
||||
}
|
||||
|
||||
using var adminClient = ApiClient(admin);
|
||||
adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
|
||||
|
||||
// The value the admin SPA's JSON.parse actually produced for this
|
||||
// id (from the bug report log) — the exact failure mode.
|
||||
const long mangled = 7597291079085523000L;
|
||||
Assert.NotEqual(BigInstanceId, mangled);
|
||||
using var pull = await adminClient.PostAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/instances/{mangled}/pull",
|
||||
new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
|
||||
Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.NotFound, pull.StatusCode);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class CanonicalRoomBootstrapTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public CanonicalRoomBootstrapTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_seeds_stunt_runner_subroom_and_rec_rally_remote_image_name()
|
||||
{
|
||||
using var _ = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
var stuntRunner = await db.Rooms.SingleAsync(r => r.Name == "StuntRunner");
|
||||
var stuntRunnerScenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == stuntRunner.Id)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Collection(
|
||||
stuntRunnerScenes,
|
||||
scene =>
|
||||
{
|
||||
Assert.Equal(0, scene.OrderIndex);
|
||||
Assert.Equal("StuntRunner", scene.Name);
|
||||
Assert.Equal("b7281665-a715-4051-826b-8e08e69c6172", scene.RoomSceneLocationId);
|
||||
Assert.Equal("", scene.DataBlobName);
|
||||
},
|
||||
scene =>
|
||||
{
|
||||
Assert.Equal(1, scene.OrderIndex);
|
||||
Assert.Equal("TheMainEvent", scene.Name);
|
||||
Assert.Equal("3a636bd2-f896-424c-9225-c184522c0d87", scene.RoomSceneLocationId);
|
||||
Assert.Equal("", scene.DataBlobName);
|
||||
});
|
||||
|
||||
var recRally = await db.Rooms.SingleAsync(r => r.Name == "RecRally");
|
||||
Assert.Equal("image_RecRally.jpg", recRally.ImageName);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the 3D Charades word-list feature and the
|
||||
/// profanity-filter toggle. Boots the full server (which runs the seeder)
|
||||
/// against SQLite via <see cref="DorkNetServerFactory"/> and exercises the
|
||||
/// public wire endpoints the March 2023 client actually calls.
|
||||
/// </summary>
|
||||
public sealed class CharadesWordListTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public CharadesWordListTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
private HttpClient ApiClient()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Charades")] // enum-name form
|
||||
[InlineData("CharadesAprilFoolsDay")]
|
||||
[InlineData("Icebreakers")]
|
||||
[InlineData("0")] // int form the client may send
|
||||
[InlineData("1")]
|
||||
[InlineData("2")]
|
||||
public async Task Words_endpoint_returns_seeded_deck_in_wire_shape(string source)
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.GetAsync($"/api/activities/charades/v1/words/{source}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0, $"expected a non-empty deck for '{source}'");
|
||||
|
||||
var first = doc.RootElement[0];
|
||||
// Exact wire key casing the client's LitJson importer reads.
|
||||
Assert.True(first.TryGetProperty("EN_US", out var en) && en.ValueKind == JsonValueKind.String,
|
||||
"each card must expose a string EN_US field");
|
||||
Assert.True(first.TryGetProperty("Difficulty", out var diff) && diff.ValueKind == JsonValueKind.Number,
|
||||
"each card must expose a numeric Difficulty field");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task December_noparam_route_serves_the_charades_slot()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.GetAsync("/api/activities/charades/v1/words");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_source_falls_back_to_charades_slot()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.GetAsync("/api/activities/charades/v1/words/totally-bogus");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Profanity_filter_is_active_by_default()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.PostAsync(
|
||||
"/api/sanitize/v1/requestIsStringPure",
|
||||
new StringContent("{\"Value\":\"you piece of shit\"}", Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
var body = (await res.Content.ReadAsStringAsync()).Trim();
|
||||
// Default state: filter on → the profane string is NOT pure.
|
||||
Assert.Equal("false", body.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Starting a chat with a player is a two-step handshake, and both steps have to
|
||||
/// work on a thread that has never carried a message:
|
||||
///
|
||||
/// POST thread/withmembers?ids={playerId} -> creates/returns the thread
|
||||
/// GET thread/{id} -> opens it
|
||||
///
|
||||
/// Observed failing in production: the POST returned 200 at 06:38:20.862 and the
|
||||
/// GET 404'd 415ms later, because the handler decided a thread existed by
|
||||
/// counting its messages. A brand-new conversation has none, so the client was
|
||||
/// handed a thread id it was then told did not exist, and no chat could ever be
|
||||
/// opened with someone you had not already messaged.
|
||||
/// </summary>
|
||||
public sealed class ChatStartConversationTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ChatStartConversationTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Brand_new_conversation_can_be_opened_before_any_message_is_sent()
|
||||
{
|
||||
using var starterClient = Client();
|
||||
using var otherClient = Client();
|
||||
|
||||
var starter = await GameClientSessionFactory.CreateAsync(starterClient, _factory.ApexDomain);
|
||||
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
|
||||
|
||||
starterClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
|
||||
|
||||
// Step 1 — the client asks for the thread with that player. It sends the
|
||||
// ids as a form body (observed on the wire as
|
||||
// "ids=9741717&messageCount=50"), which also matters for routing: the
|
||||
// content type is what separates this from POST thread/{chatThreadId},
|
||||
// whose parameter segment otherwise also matches "withmembers".
|
||||
using var form = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
|
||||
new KeyValuePair<string, string>("messageCount", "50"),
|
||||
]);
|
||||
using var createResponse = await starterClient.PostAsync("/thread/withmembers", form);
|
||||
var createBody = await createResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(createResponse.IsSuccessStatusCode,
|
||||
$"POST /thread/withmembers -> {(int)createResponse.StatusCode}: {createBody}");
|
||||
|
||||
var thread = JsonDocument.Parse(createBody).RootElement;
|
||||
Assert.True(thread.TryGetProperty("ChatThreadId", out var idElement)
|
||||
|| thread.TryGetProperty("chatThreadId", out idElement),
|
||||
$"no thread id in the withmembers reply: {createBody}");
|
||||
var threadId = idElement.ValueKind == JsonValueKind.String
|
||||
? idElement.GetString()
|
||||
: idElement.ToString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(threadId));
|
||||
|
||||
// Step 2 — and immediately opens it. No message has been sent yet.
|
||||
using var openResponse = await starterClient.GetAsync($"/thread/{threadId}?maxCount=50");
|
||||
var openBody = await openResponse.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.True(openResponse.StatusCode != HttpStatusCode.NotFound,
|
||||
$"""
|
||||
GET /thread/{threadId} returned 404 for a thread that thread/withmembers
|
||||
had just created. An empty thread is not a missing thread — the chat
|
||||
cannot be opened until someone has already sent a message, which is
|
||||
impossible to do first.
|
||||
""");
|
||||
Console.WriteLine("=== WITHMEMBERS ===");
|
||||
Console.WriteLine(createBody);
|
||||
Console.WriteLine("=== GET THREAD ===");
|
||||
Console.WriteLine(openBody);
|
||||
|
||||
Assert.True(openResponse.IsSuccessStatusCode,
|
||||
$"GET /thread/{threadId} -> {(int)openResponse.StatusCode}: {openBody}");
|
||||
}
|
||||
|
||||
private HttpClient Client()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://chat.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts our responses carry EXACTLY the keys the 2023-03-21 client's
|
||||
/// generated readers register — no missing ones, no invented ones.
|
||||
///
|
||||
/// This is stricter than "the client doesn't crash" on purpose. A wrong key set
|
||||
/// does not produce a bad request: the server answers HTTP 200, the reader
|
||||
/// throws while deserialising, and it surfaces later as a bare "Failed to copy
|
||||
/// room" with no URL and no payload logged on either side. By the time it is
|
||||
/// visible there is nothing left to diagnose it with.
|
||||
///
|
||||
/// The expected sets are extracted straight from the binary by
|
||||
/// <c>tools/extract-client-dto-keys.py</c> into
|
||||
/// <c>data/client-dto-keys-2023.json</c>, keyed by the obfuscated reader type.
|
||||
/// Cpp2IL emits each reader's property names as string literals in registration
|
||||
/// order, so that file is the client's own contract rather than our reading of
|
||||
/// it — regenerate it rather than editing it by hand.
|
||||
/// </summary>
|
||||
public sealed class ClientDtoParityTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ClientDtoParityTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
/// <summary>GLEGPFFPDBE — the room-details reader. This is what
|
||||
/// <c>GET rooms/{id}</c> and <c>POST rooms/{id}/clone</c> both deserialise
|
||||
/// into (clone's return type FGCPNAACHIK, NLDBPDCNNCF.txt:4676).</summary>
|
||||
private const string RoomDetailsReader = "GLEGPFFPDBE";
|
||||
|
||||
/// <summary>EKEFFNBIOHJ — the chat-thread reader, shared by
|
||||
/// <c>POST thread/withmembers</c> and <c>GET thread/{id}</c>.</summary>
|
||||
private const string ChatThreadReader = "EKEFFNBIOHJ";
|
||||
|
||||
/// <summary>Keys the reader registers that we deliberately do NOT send.
|
||||
///
|
||||
/// A registered slot does not oblige us to fill it — Newtonsoft leaves an
|
||||
/// absent key at its default. These two were added for parity and broke
|
||||
/// the dorm: which blob a room loads is decided per INSTANCE (the matchmake
|
||||
/// response carries it, and a dorm's is per-player, dorm_p{id}_v*.dat) and
|
||||
/// per SUB-ROOM. A room-scoped answer to the same question is one the
|
||||
/// client preferred, and for a dorm it named a file that was never written,
|
||||
/// so the room came back "corrupt".
|
||||
///
|
||||
/// Every room loaded fine while these were absent. Their absence is the
|
||||
/// fix, not a gap to close later — do not "restore parity" here without a
|
||||
/// dorm join to prove it.</summary>
|
||||
private static readonly HashSet<(string Reader, string Key)> DeliberatelyOmitted =
|
||||
[
|
||||
(RoomDetailsReader, "DataBlob"),
|
||||
(RoomDetailsReader, "DataBlobHash"),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task Room_details_carries_exactly_the_keys_the_client_reads()
|
||||
{
|
||||
using var client = RoomsClient();
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Parity{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = session.PlayerId,
|
||||
CloningAllowed = true,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, Name = "Home", OrderIndex = 0 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var response = await client.GetAsync($"/rooms/{roomId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET /rooms/{roomId} -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
AssertKeyParity(RoomDetailsReader, body, $"GET rooms/{roomId}");
|
||||
|
||||
// The reader walks into the nested objects too, and a wrong key set
|
||||
// there fails exactly the same way — a 200 the client throws on.
|
||||
var details = JsonDocument.Parse(body).RootElement;
|
||||
foreach (var (property, reader) in NestedReaders)
|
||||
{
|
||||
if (!details.TryGetProperty(property, out var value)) continue;
|
||||
|
||||
var element = value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array when value.GetArrayLength() > 0 => value[0],
|
||||
JsonValueKind.Object => value,
|
||||
_ => default,
|
||||
};
|
||||
if (element.ValueKind != JsonValueKind.Object) continue;
|
||||
|
||||
AssertKeyParity(reader, element.GetRawText(),
|
||||
$"GET rooms/{roomId} → {property}{(value.ValueKind == JsonValueKind.Array ? "[0]" : "")}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nested objects in the room-details payload and the reader each
|
||||
/// one is deserialised by. Sub-room 0, the first role, the stats object and
|
||||
/// the first tag all have their own generated readers, and a key set that
|
||||
/// disagrees with them breaks the whole room just as hard as a wrong
|
||||
/// top-level one.</summary>
|
||||
private static readonly (string Property, string Reader)[] NestedReaders =
|
||||
[
|
||||
("SubRooms", "JDEKAFDPGBE"),
|
||||
("Roles", "LMEECKCCPHG"),
|
||||
("Stats", "MLHBPDKBABN"),
|
||||
("Tags", "GEGADPCDGBO"),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task Chat_thread_carries_exactly_the_keys_the_client_reads()
|
||||
{
|
||||
using var client = ChatClient();
|
||||
using var otherClient = ChatClient();
|
||||
var starter = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
|
||||
|
||||
using var form = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
|
||||
new KeyValuePair<string, string>("messageCount", "50"),
|
||||
]);
|
||||
using var response = await client.PostAsync("/thread/withmembers", form);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"POST /thread/withmembers -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
AssertKeyParity(ChatThreadReader, body, "POST thread/withmembers");
|
||||
}
|
||||
|
||||
/// <summary>Compare one response object's keys against the reader's set.
|
||||
/// Matching is case-insensitive because the readers register three spellings
|
||||
/// per field (Pascal, camel, lower) and accept any of them — casing is the
|
||||
/// one thing they are NOT strict about.</summary>
|
||||
private static void AssertKeyParity(string readerType, string body, string what)
|
||||
{
|
||||
var expected = LoadReaderKeys(readerType)
|
||||
.Where(k => !DeliberatelyOmitted.Contains((readerType, k)))
|
||||
.ToList();
|
||||
var actual = JsonDocument.Parse(body).RootElement
|
||||
.EnumerateObject().Select(p => p.Name)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var missing = expected.Where(k => !actual.Contains(k)).ToList();
|
||||
var extra = actual
|
||||
.Where(k => !expected.Contains(k, StringComparer.OrdinalIgnoreCase))
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
Assert.True(missing.Count == 0 && extra.Count == 0,
|
||||
$"""
|
||||
{what} does not match what the client's {readerType} reader expects.
|
||||
|
||||
MISSING ({missing.Count}) — the reader registers these and we never send them;
|
||||
a non-nullable field left null is what throws mid-deserialise:
|
||||
{string.Join(", ", missing)}
|
||||
|
||||
UNEXPECTED ({extra.Count}) — we send these and the reader has no slot for them:
|
||||
{string.Join(", ", extra)}
|
||||
|
||||
Expected, in the reader's own registration order:
|
||||
{string.Join(", ", expected)}
|
||||
""");
|
||||
}
|
||||
|
||||
private static List<string> LoadReaderKeys(string readerType)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-dto-keys-2023.json");
|
||||
Assert.True(File.Exists(path),
|
||||
$"missing {path} — regenerate with tools/extract-client-dto-keys.py");
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
Assert.True(doc.RootElement.TryGetProperty(readerType, out var keys),
|
||||
$"{readerType} is not in the extracted catalogue");
|
||||
return keys.EnumerateArray().Select(k => k.GetString()!).ToList();
|
||||
}
|
||||
|
||||
private HttpClient RoomsClient() => ClientFor("rooms");
|
||||
|
||||
private HttpClient ChatClient() => ClientFor("chat");
|
||||
|
||||
private HttpClient ClientFor(string host)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that every HTTP request the Rec Room 2023-03-21 client can issue has
|
||||
/// a handler on this server, under the verb the client actually uses.
|
||||
///
|
||||
/// Rec Room is shut down, so the client can't be observed against a live server.
|
||||
/// <c>data/client-routes-2023.tsv</c> is the recovered inventory of what it
|
||||
/// asks for — see <c>docs/recroom-2023-client-api-complete.md</c> for how it was
|
||||
/// extracted. This test is the regression guard on that inventory: adding a
|
||||
/// client route without a handler, or moving a handler to a different verb,
|
||||
/// fails here rather than silently 404/405-ing a real player.
|
||||
///
|
||||
/// Routes not yet implemented live in <see cref="KnownGaps"/>. That list is
|
||||
/// expected to shrink and never grow. Deleting an entry as you implement it is
|
||||
/// the point — it is the project's running score against the client.
|
||||
/// </summary>
|
||||
public sealed class ClientRouteCoverageTests
|
||||
{
|
||||
/// <summary>Client routes with no handler yet. Each entry is
|
||||
/// <c>VERB route</c> exactly as it appears in the inventory file.
|
||||
///
|
||||
/// This is EMPTY, and that is the point: every request the 2023-03-21
|
||||
/// client can issue reaches a handler under the verb it actually uses. Do
|
||||
/// not add an entry to make a red build green — an entry here is a promise
|
||||
/// that the client gets a 404 or 405 in the wild. Implement the route, or
|
||||
/// if the inventory itself is wrong (a literal that is really a cache key,
|
||||
/// a deeplink, or a base the client concatenates onto), fix the inventory
|
||||
/// so the claim stays honest.</summary>
|
||||
private static readonly HashSet<string> KnownGaps = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[Fact]
|
||||
public void Every_client_route_has_a_handler()
|
||||
{
|
||||
var client = LoadClientRoutes();
|
||||
Assert.NotEmpty(client);
|
||||
|
||||
var discovered = EndpointContractDiscovery.Discover()
|
||||
.Select(r => (Verb: r.Method.ToUpperInvariant(), Pattern: Normalise(r.Template)))
|
||||
.ToList();
|
||||
|
||||
var server = discovered.ToHashSet();
|
||||
|
||||
// A bare [Route] with no verb attribute serves every verb.
|
||||
var verbless = discovered
|
||||
.Where(d => d.Verb == "ANY")
|
||||
.Select(d => d.Pattern)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var serverAnyVerb = discovered.Select(s => s.Pattern).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = new List<string>();
|
||||
var wrongVerb = new List<string>();
|
||||
|
||||
foreach (var (verb, route, _) in client)
|
||||
{
|
||||
var pattern = Normalise(route);
|
||||
if (server.Contains((verb, pattern)) || verbless.Contains(pattern)) continue;
|
||||
|
||||
// Distinguish "no handler at all" from "handler exists, wrong verb" —
|
||||
// they have different causes and different fixes.
|
||||
if (serverAnyVerb.Contains(pattern)) wrongVerb.Add($"{verb} {route}");
|
||||
else missing.Add($"{verb} {route}");
|
||||
}
|
||||
|
||||
var gaps = missing.Concat(wrongVerb)
|
||||
.Where(g => !KnownGaps.Contains(g))
|
||||
.OrderBy(g => g, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var covered = client.Count - missing.Count - wrongVerb.Count;
|
||||
var pct = 100.0 * covered / client.Count;
|
||||
|
||||
Assert.True(gaps.Count == 0,
|
||||
$"""
|
||||
Client route coverage: {covered}/{client.Count} ({pct:F1}%).
|
||||
{missing.Count} missing, {wrongVerb.Count} wrong-verb, {KnownGaps.Count} known gaps.
|
||||
|
||||
Unlisted gaps ({gaps.Count}) — implement them, or add to KnownGaps with a reason:
|
||||
{string.Join(Environment.NewLine, gaps)}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Collapse a route to a comparable shape: no host, no leading or
|
||||
/// trailing slash, no query, and every parameter — the client's <c>{0}</c>
|
||||
/// and the server's <c>{id:long}</c> alike — reduced to <c>*</c>.</summary>
|
||||
private static string Normalise(string route)
|
||||
{
|
||||
var r = route.Trim();
|
||||
var q = r.IndexOf('?');
|
||||
if (q >= 0) r = r[..q];
|
||||
r = r.Trim('/', '~');
|
||||
r = Regex.Replace(r, @"\{[^}]*\}", "*");
|
||||
return r.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static List<(string Verb, string Route, string Subsystem)> LoadClientRoutes()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-routes-2023.tsv");
|
||||
Assert.True(File.Exists(path), $"missing route inventory at {path}");
|
||||
|
||||
var rows = new List<(string, string, string)>();
|
||||
foreach (var line in File.ReadLines(path))
|
||||
{
|
||||
if (line.Length == 0 || line[0] == '#') continue;
|
||||
var parts = line.Split('\t');
|
||||
if (parts.Length < 2) continue;
|
||||
rows.Add((parts[0].Trim().ToUpperInvariant(), parts[1].Trim(),
|
||||
parts.Length > 2 ? parts[2].Trim() : string.Empty));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
|
||||
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Recovered client route inventory read by ClientRouteCoverageTests. -->
|
||||
<Content Include="data\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,90 +0,0 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.HostFiltering;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private const string JwtSecret = "endpoint-contract-secret-000000000000000000000000000000000000000000";
|
||||
private readonly string _dataRoot;
|
||||
|
||||
public DorkNetServerFactory()
|
||||
{
|
||||
_dataRoot = Path.Combine(Path.GetTempPath(), "dorknet-server-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dataRoot);
|
||||
}
|
||||
|
||||
public string ApexDomain => "dork.test";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.UseSetting("Domain:Apex", ApexDomain);
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AllowedHosts"] = "*",
|
||||
["Database:Provider"] = "sqlite",
|
||||
["Database:SqlitePath"] = Path.Combine(_dataRoot, "dorknet-test.db"),
|
||||
["Domain:Apex"] = ApexDomain,
|
||||
["Jwt:Secret"] = JwtSecret,
|
||||
["S3:Endpoint"] = "",
|
||||
["S3:AccessKey"] = "",
|
||||
["S3:SecretKey"] = "",
|
||||
["DorkNet:DefaultClientVersion"] = "march_2023_03_21",
|
||||
["DorkNet:SupportedVersions:0"] = "march_2023_03_21",
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.PostConfigure<HostFilteringOptions>(options =>
|
||||
{
|
||||
options.AllowedHosts =
|
||||
[
|
||||
ApexDomain,
|
||||
$"*.{ApexDomain}",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
];
|
||||
options.AllowEmptyHosts = true;
|
||||
options.IncludeFailureMessage = true;
|
||||
});
|
||||
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "https://api.rec.net/",
|
||||
ValidateAudience = false,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_dataRoot))
|
||||
{
|
||||
Directory.Delete(_dataRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Test cleanup must not hide the original test failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public static partial class EndpointContractDiscovery
|
||||
{
|
||||
public static IReadOnlyList<EndpointContract> Discover()
|
||||
{
|
||||
var serverAssembly = typeof(Program).Assembly;
|
||||
var controllerBaseType = typeof(ControllerBase);
|
||||
return serverAssembly.GetTypes()
|
||||
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t))
|
||||
.SelectMany(DiscoverControllerRoutes)
|
||||
.OrderBy(route => route.Method, StringComparer.Ordinal)
|
||||
.ThenBy(route => route.SamplePath, StringComparer.Ordinal)
|
||||
.ThenBy(route => route.Source, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<EndpointContract> DiscoverControllerRoutes(Type controller)
|
||||
{
|
||||
var controllerName = ControllerName(controller);
|
||||
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
|
||||
.OfType<IRouteTemplateProvider>()
|
||||
.Select(a => a.Template)
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Cast<string>()
|
||||
.DefaultIfEmpty("")
|
||||
.ToArray();
|
||||
|
||||
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
var httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<HttpMethodAttribute>()
|
||||
.Cast<IRouteTemplateProvider>()
|
||||
.ToArray();
|
||||
|
||||
// An action can carry a bare [Route("...")] with no [HttpGet]/[HttpPost],
|
||||
// which routes ALL verbs — the CDN's unity_assets handler is the live
|
||||
// example. Skipping those made the discovered table under-report the
|
||||
// server, so route-coverage checks saw phantom gaps.
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<IRouteTemplateProvider>()
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a.Template))
|
||||
.ToArray();
|
||||
}
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attr in httpAttrs)
|
||||
{
|
||||
var attrMethods = attr is HttpMethodAttribute { HttpMethods: var verbs } && verbs.Any()
|
||||
? verbs
|
||||
: new[] { "ANY" };
|
||||
|
||||
foreach (var httpMethod in attrMethods)
|
||||
{
|
||||
foreach (var controllerTemplate in controllerRouteTemplates)
|
||||
{
|
||||
var template = CombineTemplates(controllerTemplate, attr.Template);
|
||||
template = ReplaceRouteTokens(template, controllerName, method.Name);
|
||||
yield return new EndpointContract(
|
||||
httpMethod.ToUpperInvariant(),
|
||||
template,
|
||||
ToSamplePath(template),
|
||||
$"{controller.FullName}.{method.Name}",
|
||||
RequiresAuthorization(controller, method),
|
||||
IsAdminEndpoint(controller, method),
|
||||
ConsumesContentTypes(controller, method));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RequiresAuthorization(Type controller, MethodInfo method)
|
||||
{
|
||||
var attributes = controller.GetCustomAttributes(inherit: true)
|
||||
.Concat(method.GetCustomAttributes(inherit: true))
|
||||
.ToArray();
|
||||
|
||||
if (attributes.OfType<IAllowAnonymous>().Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return attributes.OfType<IAuthorizeData>().Any();
|
||||
}
|
||||
|
||||
private static bool IsAdminEndpoint(Type controller, MethodInfo method)
|
||||
{
|
||||
return controller.FullName?.Contains(".Controllers.Admin.", StringComparison.Ordinal) == true
|
||||
|| method.Name.Contains("Admin", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string[] ConsumesContentTypes(Type controller, MethodInfo method)
|
||||
{
|
||||
var consumes = method.GetCustomAttributes(inherit: true)
|
||||
.Concat(controller.GetCustomAttributes(inherit: true))
|
||||
.OfType<ConsumesAttribute>()
|
||||
.SelectMany(a => a.ContentTypes)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return consumes.Length == 0
|
||||
? ["application/json"]
|
||||
: consumes;
|
||||
}
|
||||
|
||||
private static string ControllerName(Type controller)
|
||||
{
|
||||
const string suffix = "Controller";
|
||||
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
|
||||
? controller.Name[..^suffix.Length]
|
||||
: controller.Name;
|
||||
}
|
||||
|
||||
private static string CombineTemplates(string? controllerTemplate, string? actionTemplate)
|
||||
{
|
||||
controllerTemplate ??= "";
|
||||
actionTemplate ??= "";
|
||||
|
||||
if (actionTemplate.StartsWith("~/", StringComparison.Ordinal))
|
||||
{
|
||||
return "/" + actionTemplate[2..].TrimStart('/');
|
||||
}
|
||||
|
||||
if (actionTemplate.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
return "/" + actionTemplate.TrimStart('/');
|
||||
}
|
||||
|
||||
var left = controllerTemplate.Trim('/');
|
||||
var right = actionTemplate.Trim('/');
|
||||
|
||||
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(left))
|
||||
{
|
||||
return "/" + right;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(right))
|
||||
{
|
||||
return "/" + left;
|
||||
}
|
||||
|
||||
return "/" + left + "/" + right;
|
||||
}
|
||||
|
||||
private static string ReplaceRouteTokens(string template, string controllerName, string actionName)
|
||||
{
|
||||
return template
|
||||
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ToSamplePath(string template)
|
||||
{
|
||||
var path = RouteParameterRegex().Replace(template, match =>
|
||||
{
|
||||
var name = match.Groups["name"].Value.ToLowerInvariant();
|
||||
var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
|
||||
|
||||
if (name.Contains("playerid") || name.Contains("accountid") || name == "id")
|
||||
{
|
||||
return EndpointContract.TestPlayerToken;
|
||||
}
|
||||
|
||||
if (constraint.Contains("long") || constraint.Contains("int") || name.EndsWith("id"))
|
||||
{
|
||||
return "1";
|
||||
}
|
||||
|
||||
if (constraint.Contains("bool"))
|
||||
{
|
||||
return "true";
|
||||
}
|
||||
|
||||
if (constraint.Contains("guid"))
|
||||
{
|
||||
return "00000000-0000-0000-0000-000000000001";
|
||||
}
|
||||
|
||||
if (name.Contains("roomname"))
|
||||
{
|
||||
return "DormRoom";
|
||||
}
|
||||
|
||||
if (name.Contains("locale"))
|
||||
{
|
||||
return "en";
|
||||
}
|
||||
|
||||
return "sample";
|
||||
});
|
||||
|
||||
path = Regex.Replace(path, "/+", "/");
|
||||
return path.StartsWith('/') ? path : "/" + path;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
|
||||
private static partial Regex RouteParameterRegex();
|
||||
}
|
||||
|
||||
public sealed record EndpointContract(
|
||||
string Method,
|
||||
string Template,
|
||||
string SamplePath,
|
||||
string Source,
|
||||
bool RequiresAuthorization,
|
||||
bool IsAdminEndpoint,
|
||||
string[] Consumes)
|
||||
{
|
||||
public const string TestPlayerToken = "__test_player_id__";
|
||||
|
||||
public string PathFor(GameClientSession session)
|
||||
{
|
||||
return SamplePath.Replace(
|
||||
TestPlayerToken,
|
||||
session.PlayerId.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class GameClientEndpointContractTests :
|
||||
IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public GameClientEndpointContractTests(DorkNetServerFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Game_client_contract_probe_reaches_every_controller_route()
|
||||
{
|
||||
var routes = EndpointContractDiscovery.Discover()
|
||||
.Where(r => !DestroysTheProbeSession(r))
|
||||
.ToList();
|
||||
Assert.NotEmpty(routes);
|
||||
|
||||
using var client = _factory.CreateClient(new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
});
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var route in routes)
|
||||
{
|
||||
var result = await ProbeAsync(client, route, session, _factory.ApexDomain);
|
||||
if (!result.Passed)
|
||||
{
|
||||
failures.Add(
|
||||
$"{route.Method,-6} {result.Host}{result.Path} {route.Source} expected {result.Expected}, got {result.Observed}"
|
||||
+ (string.IsNullOrWhiteSpace(result.ResponseExcerpt) ? "" : $" body={result.ResponseExcerpt}"));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
failures.Count == 0,
|
||||
$"Endpoint contract failures ({failures.Count}/{routes.Count}):{Environment.NewLine}"
|
||||
+ string.Join(Environment.NewLine, failures));
|
||||
}
|
||||
|
||||
/// <summary>Routes this probe must not call, because they destroy the very
|
||||
/// session it authenticates with.
|
||||
///
|
||||
/// The probe walks every route in order using one test player. Account
|
||||
/// deletion is a genuine, correctly-guarded purge — but once it runs, that
|
||||
/// player row is gone, so every later probe fails on a foreign-key
|
||||
/// violation or a 401. Excluding these keeps the failure list meaningful
|
||||
/// instead of one real event cascading into dozens of phantom failures.
|
||||
///
|
||||
/// This is deliberately narrow: only self-destruct routes belong here, not
|
||||
/// anything merely inconvenient.</summary>
|
||||
private static bool DestroysTheProbeSession(EndpointContract route)
|
||||
{
|
||||
if (!string.Equals(route.Method, "DELETE", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var template = route.Template.Trim('/');
|
||||
return template is "account/me" or "account/v1/me" or "account/v2/me";
|
||||
}
|
||||
|
||||
private static async Task<EndpointProbeResult> ProbeAsync(
|
||||
HttpClient client,
|
||||
EndpointContract route,
|
||||
GameClientSession session,
|
||||
string apexDomain)
|
||||
{
|
||||
var path = route.PathFor(session);
|
||||
path = AddClientQuery(path, route, session);
|
||||
var host = HostFor(path, apexDomain);
|
||||
using var request = new HttpRequestMessage(
|
||||
ToHttpMethod(route.Method),
|
||||
new Uri($"http://{host}{path}"));
|
||||
request.Headers.Accept.ParseAdd("application/json");
|
||||
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
if (NeedsRequestBody(request.Method))
|
||||
{
|
||||
request.Content = CreateContent(route, session);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.SendAsync(request);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var passed = ResponseMatchesContract(route, response.StatusCode);
|
||||
return new EndpointProbeResult(
|
||||
route.Method,
|
||||
host,
|
||||
path,
|
||||
ExpectedContract(route),
|
||||
statusCode,
|
||||
null,
|
||||
Excerpt(responseText),
|
||||
passed);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
return new EndpointProbeResult(
|
||||
route.Method,
|
||||
host,
|
||||
path,
|
||||
ExpectedContract(route),
|
||||
null,
|
||||
ex.Message,
|
||||
null,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ResponseMatchesContract(
|
||||
EndpointContract route,
|
||||
HttpStatusCode statusCode)
|
||||
{
|
||||
if ((int)statusCode >= 500)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (statusCode is HttpStatusCode.MethodNotAllowed
|
||||
or HttpStatusCode.NotImplemented
|
||||
or HttpStatusCode.UpgradeRequired)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (route.RequiresAuthorization
|
||||
&& statusCode is HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ExpectedContract(EndpointContract route)
|
||||
{
|
||||
if (route.IsAdminEndpoint)
|
||||
{
|
||||
return "admin endpoint may reject non-admin test player, but must not 5xx/405/426";
|
||||
}
|
||||
|
||||
if (route.RequiresAuthorization)
|
||||
{
|
||||
return "authenticated test player should reach the action without 401/5xx/405/426; 403 is valid for owner/admin-only resources";
|
||||
}
|
||||
|
||||
return "game-client request should not 5xx/405/426";
|
||||
}
|
||||
|
||||
private static HttpMethod ToHttpMethod(string method)
|
||||
{
|
||||
return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase)
|
||||
? HttpMethod.Get
|
||||
: new HttpMethod(method);
|
||||
}
|
||||
|
||||
private static bool NeedsRequestBody(HttpMethod method)
|
||||
{
|
||||
return method == HttpMethod.Post
|
||||
|| method == HttpMethod.Put
|
||||
|| method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HttpContent CreateContent(
|
||||
EndpointContract route,
|
||||
GameClientSession session)
|
||||
{
|
||||
var consumes = route.Consumes.Select(c => c.ToLowerInvariant()).ToArray();
|
||||
if (consumes.Any(c => c.Contains("application/x-www-form-urlencoded")))
|
||||
{
|
||||
return new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["account_id"] = session.PlayerId.ToString(),
|
||||
["AccountId"] = session.PlayerId.ToString(),
|
||||
["clubId"] = "1",
|
||||
["ClubId"] = "1",
|
||||
["description"] = "Endpoint contract description",
|
||||
["deviceId"] = session.DeviceId,
|
||||
["grant_type"] = "password",
|
||||
["image"] = "endpoint-contract.png",
|
||||
["imageName"] = "endpoint-contract.png",
|
||||
["fileName"] = "endpoint-contract.png",
|
||||
["message"] = "Endpoint contract message",
|
||||
["name"] = "EndpointContract",
|
||||
["newPassword"] = "endpoint-contract-password",
|
||||
["oldPassword"] = "",
|
||||
["password"] = "endpoint-contract-password",
|
||||
["ping"] = "42",
|
||||
["platformId"] = session.PlatformId,
|
||||
["playerId"] = session.PlayerId.ToString(),
|
||||
["playerIds"] = session.PlayerId.ToString(),
|
||||
["query"] = "DormRoom",
|
||||
["region"] = "us",
|
||||
["targetPlayerId"] = session.PlayerId.ToString(),
|
||||
["text"] = "Endpoint contract message",
|
||||
["username"] = "EndpointContract",
|
||||
["device_id"] = session.DeviceId,
|
||||
["platform"] = "0",
|
||||
["platform_id"] = session.PlatformId,
|
||||
["Ids"] = session.PlayerId.ToString(),
|
||||
["PlayerIds"] = session.PlayerId.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (consumes.Any(c => c.Contains("multipart/form-data")))
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
content.Add(new StringContent("endpoint-contract"), "name");
|
||||
content.Add(new StringContent(session.PlayerId.ToString()), "playerId");
|
||||
content.Add(new StringContent("endpoint-contract.png"), "imageName");
|
||||
content.Add(new StringContent("endpoint-contract.png"), "fileName");
|
||||
content.Add(new ByteArrayContent([]), "file", "endpoint-contract.bin");
|
||||
return content;
|
||||
}
|
||||
|
||||
if (consumes.Any(c => c.Contains("text/plain")))
|
||||
{
|
||||
return new StringContent("", Encoding.UTF8, "text/plain");
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
PlayerId = session.PlayerId,
|
||||
AccountId = session.PlayerId,
|
||||
ClubId = 1,
|
||||
Description = "Endpoint contract description",
|
||||
DeviceId = session.DeviceId,
|
||||
FileName = "endpoint-contract.png",
|
||||
Id = session.PlayerId,
|
||||
Image = "endpoint-contract.png",
|
||||
ImageName = "endpoint-contract.png",
|
||||
Message = "Endpoint contract message",
|
||||
Platform = 0,
|
||||
PlatformId = session.PlatformId,
|
||||
PlayerIds = new[] { session.PlayerId },
|
||||
Name = "EndpointContract",
|
||||
Password = "endpoint-contract-password",
|
||||
Query = "DormRoom",
|
||||
RoomName = "DormRoom",
|
||||
TargetPlayerId = session.PlayerId,
|
||||
Text = "Endpoint contract message",
|
||||
});
|
||||
return new StringContent(json, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
private static string AddClientQuery(
|
||||
string path,
|
||||
EndpointContract route,
|
||||
GameClientSession session)
|
||||
{
|
||||
var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?";
|
||||
var lower = path.ToLowerInvariant();
|
||||
var query = new List<string>();
|
||||
|
||||
if (lower.Contains("search", StringComparison.Ordinal)
|
||||
|| lower.Contains("query", StringComparison.Ordinal))
|
||||
{
|
||||
query.Add("query=EndpointContract");
|
||||
}
|
||||
|
||||
if (lower == "/members/bulk")
|
||||
{
|
||||
query.Add("clubId=1");
|
||||
query.Add($"playerIds={session.PlayerId}");
|
||||
}
|
||||
|
||||
if (lower == "/player/photonregionpings")
|
||||
{
|
||||
query.Add("region=us");
|
||||
query.Add("ping=42");
|
||||
}
|
||||
|
||||
if (route.Source.Contains("CachedLogins", StringComparison.Ordinal)
|
||||
|| lower.Contains("platformlogin/cached", StringComparison.Ordinal)
|
||||
|| lower.Contains("savedlogins", StringComparison.Ordinal))
|
||||
{
|
||||
query.Add("platform=0");
|
||||
query.Add($"platformId={Uri.EscapeDataString(session.PlatformId)}");
|
||||
}
|
||||
|
||||
if (route.Source.Contains("Bulk", StringComparison.Ordinal)
|
||||
&& !query.Any(q => q.StartsWith("Ids=", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
query.Add($"Ids={session.PlayerId}");
|
||||
}
|
||||
|
||||
if (query.Count == 0)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path + separator + string.Join("&", query);
|
||||
}
|
||||
|
||||
private static string HostFor(string path, string apexDomain)
|
||||
{
|
||||
var lower = path.ToLowerInvariant();
|
||||
|
||||
if (lower.StartsWith("/connect/")
|
||||
|| lower.StartsWith("/.well-known/")
|
||||
|| lower.StartsWith("/eac/")
|
||||
|| lower.StartsWith("/cachedlogin/")
|
||||
|| lower.StartsWith("/photon/")
|
||||
|| lower.StartsWith("/api/platformlogin/")
|
||||
|| lower.StartsWith("/api/version")
|
||||
|| lower.StartsWith("/api/auth/"))
|
||||
{
|
||||
return $"auth.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/account/"))
|
||||
{
|
||||
return $"accounts.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/roomserver/")
|
||||
|| lower.StartsWith("/rooms")
|
||||
|| lower.StartsWith("/playlists")
|
||||
|| lower.StartsWith("/featuredrooms")
|
||||
|| lower.StartsWith("/hot_rooms")
|
||||
|| lower.StartsWith("/search_rooms")
|
||||
|| lower.StartsWith("/api/rooms")
|
||||
|| lower.StartsWith("/api/roomkeys")
|
||||
|| lower.StartsWith("/api/gamesessions")
|
||||
|| lower.StartsWith("/api/curatedroomplaylists"))
|
||||
{
|
||||
return $"rooms.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/find")
|
||||
|| lower.StartsWith("/v1/join")
|
||||
|| lower.StartsWith("/v1/leave")
|
||||
|| lower.StartsWith("/v1/session")
|
||||
|| lower.StartsWith("/roominstance/")
|
||||
|| lower.StartsWith("/goto/")
|
||||
|| lower.StartsWith("/player/"))
|
||||
{
|
||||
return $"match.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/thread"))
|
||||
{
|
||||
return $"chat.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/club")
|
||||
|| lower.StartsWith("/announcements/")
|
||||
|| lower.StartsWith("/subscription/")
|
||||
|| lower.StartsWith("/members/")
|
||||
|| lower.StartsWith("/api/clubreporting/"))
|
||||
{
|
||||
return $"clubs.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/api/storefronts/")
|
||||
|| lower.StartsWith("/api/purchase/")
|
||||
|| lower.StartsWith("/api/subscription/"))
|
||||
{
|
||||
return $"commerce.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/hub/") || lower.StartsWith("/api/notification/"))
|
||||
{
|
||||
return $"notify.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/regions"))
|
||||
{
|
||||
return $"geo.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/services") || lower.StartsWith("/v1/ping"))
|
||||
{
|
||||
return $"ns.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/upload"))
|
||||
{
|
||||
return $"storage.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/img/") || lower.StartsWith("/room/") || lower.StartsWith("/data/"))
|
||||
{
|
||||
return $"cdn.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/api/admin/"))
|
||||
{
|
||||
return $"admin.{apexDomain}";
|
||||
}
|
||||
|
||||
return $"api.{apexDomain}";
|
||||
}
|
||||
|
||||
private static string? Excerpt(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var compact = body.ReplaceLineEndings(" ").Trim();
|
||||
return compact.Length <= 240 ? compact : compact[..240] + "...";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record EndpointProbeResult(
|
||||
string Method,
|
||||
string Host,
|
||||
string Path,
|
||||
string Expected,
|
||||
int? StatusCode,
|
||||
string? Error,
|
||||
string? ResponseExcerpt,
|
||||
bool Passed)
|
||||
{
|
||||
public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response";
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed record GameClientSession(
|
||||
long PlayerId,
|
||||
string AccessToken,
|
||||
string DeviceId,
|
||||
string PlatformId);
|
||||
|
||||
public static class GameClientSessionFactory
|
||||
{
|
||||
public static async Task<GameClientSession> CreateAsync(
|
||||
HttpClient client,
|
||||
string apexDomain)
|
||||
{
|
||||
var deviceId = $"endpoint-contract-device-{Guid.NewGuid():N}";
|
||||
var platformId = $"endpoint-contract-platform-{Guid.NewGuid():N}";
|
||||
var tokenRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
new Uri($"http://auth.{apexDomain}/connect/token"));
|
||||
tokenRequest.Headers.Accept.ParseAdd("application/json");
|
||||
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["username"] = "EndpointContract",
|
||||
["device_id"] = deviceId,
|
||||
["platform"] = "0",
|
||||
["platform_id"] = platformId,
|
||||
});
|
||||
|
||||
using var tokenResponse = await client.SendAsync(tokenRequest);
|
||||
if (!tokenResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await tokenResponse.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Test player token request failed with {(int)tokenResponse.StatusCode} {tokenResponse.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
using var tokenJson = await JsonDocument.ParseAsync(
|
||||
await tokenResponse.Content.ReadAsStreamAsync());
|
||||
var accessToken = tokenJson.RootElement.GetProperty("access_token").GetString()
|
||||
?? throw new InvalidOperationException("Token response did not include access_token.");
|
||||
|
||||
var userInfoRequest = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
new Uri($"http://auth.{apexDomain}/connect/userinfo"));
|
||||
userInfoRequest.Headers.Accept.ParseAdd("application/json");
|
||||
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using var userInfoResponse = await client.SendAsync(userInfoRequest);
|
||||
userInfoResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var userInfoJson = await JsonDocument.ParseAsync(
|
||||
await userInfoResponse.Content.ReadAsStreamAsync());
|
||||
var playerId = long.Parse(userInfoJson.RootElement.GetProperty("sub").GetString()!);
|
||||
|
||||
return new GameClientSession(playerId, accessToken, deviceId, platformId);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using System.Net;
|
||||
using DorkNet.Contracts;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for the 2023 "pull a player into a private instance"
|
||||
/// flow. The 2023 client renamed the matchmaking surface from <c>goto/*</c>
|
||||
/// to <c>matchmake/*</c> and added <c>matchmake/chatinvite/{account}/{instance}</c>
|
||||
/// for chat party invites; the server had neither the routes nor the gateway
|
||||
/// ownership entry, so those requests 404'd.
|
||||
/// </summary>
|
||||
public sealed class MatchmakePullInviteTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public MatchmakePullInviteTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
private HttpClient ApiClient()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/matchmake/instance/999")]
|
||||
[InlineData("/matchmake/invite/999")]
|
||||
[InlineData("/matchmake/chatinvite/2/16777216")] // {accountId}/{roomInstanceId}
|
||||
[InlineData("/matchmake/player/2")]
|
||||
[InlineData("/matchmake/event/999")]
|
||||
[InlineData("/matchmake/club/999")]
|
||||
[InlineData("/matchmake/code/5/ABCDEF")] // {roomId}/{code}
|
||||
public async Task Matchmake_pull_routes_exist(string path)
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.PostAsync(path, new StringContent(string.Empty));
|
||||
|
||||
// The bug was a hard 404 (no route). These resolve to a dorm-fallback
|
||||
// matchmaking response when the instance/invite doesn't exist, so any
|
||||
// non-404 status proves the route is now mounted.
|
||||
Assert.NotEqual(HttpStatusCode.NotFound, res.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/matchmake/instance/999")]
|
||||
[InlineData("/matchmake/invite/999")]
|
||||
[InlineData("/matchmake/chatinvite/2/16777216")]
|
||||
[InlineData("/matchmake/room/5")]
|
||||
[InlineData("/matchmake/player/2")]
|
||||
[InlineData("/matchmake/event/999")]
|
||||
[InlineData("/matchmake/club/999")]
|
||||
[InlineData("/matchmake/code/5/ABCDEF")]
|
||||
public void Matchmake_paths_route_to_same_service_as_goto(string path)
|
||||
{
|
||||
const string apex = "dork.test";
|
||||
var gotoOwner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", "/goto/instance/999", apex);
|
||||
var owner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", path, apex);
|
||||
|
||||
// matchmake/* must land on the same service that owns goto/* — before
|
||||
// the fix it fell through to the Monolith default owner.
|
||||
Assert.Equal(gotoOwner, owner);
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// api/avatar/v4/items — the owned/unlocked wardrobe. The 2023 client's
|
||||
/// owned-item DTO (RecNet IFNGBPJGINL) requires <c>AvatarItemId</c> (Int32),
|
||||
/// <c>TagList</c> (string) and <c>IsBaseAvatarItem</c> (bool). Omitting them
|
||||
/// left every item at AvatarItemId 0; the client keys the wardrobe by that
|
||||
/// id, so the second item collided, the unlocked-items list failed to build
|
||||
/// (empty wardrobe = "no owned items"), and any refetch — including the one
|
||||
/// after a purchase — NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew
|
||||
/// ("can't buy items"). Each item must carry a unique, non-zero id.
|
||||
/// </summary>
|
||||
public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public OwnedAvatarItemsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Owned_items_carry_unique_nonzero_ids_and_2023_keys()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
Assert.True(items.GetArrayLength() > 0, "starter wardrobe was empty");
|
||||
|
||||
var ids = new List<int>();
|
||||
foreach (var it in items.EnumerateArray())
|
||||
{
|
||||
// Every 2023 key present and correctly typed.
|
||||
Assert.Equal(JsonValueKind.Number, it.GetProperty("AvatarItemId").ValueKind);
|
||||
Assert.Equal(JsonValueKind.String, it.GetProperty("TagList").ValueKind);
|
||||
var isBase = it.GetProperty("IsBaseAvatarItem").ValueKind;
|
||||
Assert.True(isBase is JsonValueKind.True or JsonValueKind.False);
|
||||
Assert.Equal(JsonValueKind.String, it.GetProperty("AvatarItemDesc").ValueKind);
|
||||
|
||||
var id = it.GetProperty("AvatarItemId").GetInt32();
|
||||
Assert.NotEqual(0, id);
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// No duplicate ids — a collision is what crashed the wardrobe.
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Purchased_item_appears_owned_with_valid_shape()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var before = (await GetJsonAsync(client, "/api/avatar/v4/items")).GetArrayLength();
|
||||
|
||||
// Buy the first avatar-backed watch-storefront item.
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
|
||||
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
|
||||
.GetProperty("PurchasableItemId").GetInt32();
|
||||
|
||||
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
|
||||
new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
StorefrontType = 3,
|
||||
PurchasableItemId = purchasableId,
|
||||
CurrencyType = 2,
|
||||
RequestedPrice = 1,
|
||||
Gift = (object?)null,
|
||||
}), Encoding.UTF8, "application/json"));
|
||||
buy.EnsureSuccessStatusCode();
|
||||
|
||||
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
Assert.True(after.GetArrayLength() >= before, "owned count shrank after purchase");
|
||||
// Still no duplicate ids after the new item joins the list.
|
||||
var ids = after.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Buy_gift_desc_matches_v4items_and_consume_grants_once()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
// Buy an avatar-backed item — the server queues a post-purchase gift box.
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
|
||||
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
|
||||
.GetProperty("PurchasableItemId").GetInt32();
|
||||
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
|
||||
new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
StorefrontType = 3, PurchasableItemId = purchasableId,
|
||||
CurrencyType = 2, RequestedPrice = 1, Gift = (object?)null,
|
||||
}), Encoding.UTF8, "application/json"));
|
||||
buy.EnsureSuccessStatusCode();
|
||||
|
||||
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
|
||||
Assert.True(gifts.GetArrayLength() > 0, "buy did not queue a gift box");
|
||||
var gift = gifts[0];
|
||||
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
|
||||
|
||||
// The gift's desc MUST be the same 4-part "{guid},,," form v4/items
|
||||
// emits — the client matches on it verbatim in
|
||||
// OutfitManager.UnlockAvatarItemAndMarkNew after the box is consumed;
|
||||
// a bare guid missed and NRE'd ("items removed on every buy").
|
||||
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"gift desc not 4-part: {giftDesc}");
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()).ToHashSet();
|
||||
Assert.Contains(giftDesc, descs);
|
||||
|
||||
// Consuming the box must not add a SECOND wardrobe row for the item
|
||||
// the direct purchase already granted as a bare guid.
|
||||
using var consume = await client.PostAsync(
|
||||
$"/api/avatar/v2/gifts/consume/{gift.GetProperty("Id").GetInt64()}",
|
||||
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
consume.EnsureSuccessStatusCode();
|
||||
|
||||
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var baseGuid = giftDesc.Split(',')[0];
|
||||
var occurrences = after.EnumerateArray()
|
||||
.Count(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0] == baseGuid);
|
||||
Assert.Equal(1, occurrences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Legacy_bare_desc_gift_is_served_as_4part_matching_v4items()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
// Simulate a gift persisted BEFORE the fix: a bare-guid outfit desc
|
||||
// (no ",,," suffix) — the class of row that made the post-consume
|
||||
// UnlockAvatarItemAndMarkNew Find() miss and NRE.
|
||||
string bareGuid;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<Data.DorkNetDbContext>();
|
||||
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
|
||||
bareGuid = item.Slug["wardrobe-".Length..];
|
||||
db.GiftPackages.Add(new Data.Entities.GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = player.PlayerId,
|
||||
AvatarItemType = 0, // outfit
|
||||
AvatarItemDescOrHairDyeDesc = bareGuid, // BARE — the legacy bug
|
||||
IsValid = true,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
|
||||
var gift = gifts.EnumerateArray()
|
||||
.First(g => g.GetProperty("AvatarItemDesc").GetString()!.StartsWith(bareGuid));
|
||||
|
||||
// Both desc keys are served as the 4-part form regardless of how the
|
||||
// row was stored, so the client's exact-match unlock succeeds. (A
|
||||
// real buy also grants the item, so v4/items contains it — covered by
|
||||
// Purchased_item_appears_owned / Buy_gift_desc_matches_v4items.)
|
||||
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
|
||||
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"legacy gift not normalized: {giftDesc}");
|
||||
Assert.Equal(giftDesc, gift.GetProperty("AvatarItemDescOrHairDyeDesc").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Toggle_on_wardrobe_includes_color_variants_with_unique_ids()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var settings = scope.ServiceProvider
|
||||
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>();
|
||||
await settings.SetAllAvatarItemsOwnedEnabledAsync(true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = ApiClient(player);
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
|
||||
// Variant descs ("{guid},{combo},{mask},") — the colorways — are
|
||||
// present, not just base "{guid},,," rows; and every id is unique
|
||||
// (base + variants share a base guid, so a base-guid hash would
|
||||
// collide → wardrobe dict crash).
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||
var hasVariant = descs.Any(d =>
|
||||
{
|
||||
var p = d.Split(',');
|
||||
return p.Length >= 2 && p[1].Length > 0;
|
||||
});
|
||||
Assert.True(hasVariant, "toggle-on wardrobe has no colored variants");
|
||||
|
||||
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// While a join is pending, the heartbeat must tell the joining player which
|
||||
/// instance they are joining — never null.
|
||||
///
|
||||
/// Deferring the presence commit is about what everyone ELSE sees: presence
|
||||
/// stays uncommitted until the join is confirmed so a failed join leaves no
|
||||
/// ghost behind. It must not change what the joining player is told about
|
||||
/// themselves, because the client XORs (cachedRoomInstance != null) against
|
||||
/// (serverRoomInstance != null): holding the target while the server says null
|
||||
/// reads as the room vanishing mid-join. The client then logs "presence
|
||||
/// heartbeat response indicates local presence is out-of-sync", "Attempting to
|
||||
/// go to Invalid RoomInstance", and cancels the very join the server was
|
||||
/// waiting on — so it retries until a heartbeat happens to miss the window, and
|
||||
/// entering a dorm takes several attempts while looking like a load failure.
|
||||
/// </summary>
|
||||
public sealed class PendingJoinPresenceTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PendingJoinPresenceTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Heartbeat_during_a_pending_dorm_join_reports_the_instance_being_joined()
|
||||
{
|
||||
using var client = Client("match");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
// matchmake/dorm is what arms the deferred-commit pending join.
|
||||
using var dormForm = new FormUrlEncodedContent(
|
||||
[new KeyValuePair<string, string>("MaxPersistenceVersion", "45")]);
|
||||
using var dorm = await client.PostAsync("/matchmake/dorm", dormForm);
|
||||
var dormBody = await dorm.Content.ReadAsStringAsync();
|
||||
Assert.True(dorm.IsSuccessStatusCode,
|
||||
$"POST /matchmake/dorm -> {(int)dorm.StatusCode}: {dormBody}");
|
||||
|
||||
var expectedInstance = JsonDocument.Parse(dormBody).RootElement;
|
||||
var instanceId = FindInstanceId(expectedInstance);
|
||||
Assert.True(instanceId is not null, $"no roomInstanceId in the dorm reply: {dormBody}");
|
||||
|
||||
// The join has NOT been reported yet, so the commit is still deferred —
|
||||
// this is exactly the window the client heartbeats in.
|
||||
using var beatForm = new FormUrlEncodedContent(
|
||||
[new KeyValuePair<string, string>("LoginLock", Guid.NewGuid().ToString())]);
|
||||
using var beat = await client.PostAsync("/player/heartbeat", beatForm);
|
||||
var beatBody = await beat.Content.ReadAsStringAsync();
|
||||
Assert.True(beat.IsSuccessStatusCode,
|
||||
$"POST /player/heartbeat -> {(int)beat.StatusCode}: {beatBody}");
|
||||
|
||||
var presence = JsonDocument.Parse(beatBody).RootElement;
|
||||
Assert.True(presence.TryGetProperty("roomInstance", out var reported)
|
||||
|| presence.TryGetProperty("RoomInstance", out reported),
|
||||
$"no roomInstance key in the heartbeat: {beatBody}");
|
||||
|
||||
Assert.True(reported.ValueKind != JsonValueKind.Null,
|
||||
$"""
|
||||
The heartbeat returned a null roomInstance while the player's join was
|
||||
still pending. The client reads that as the room vanishing mid-join and
|
||||
cancels the load it is waiting on:
|
||||
|
||||
{beatBody}
|
||||
""");
|
||||
|
||||
Assert.Equal(instanceId, FindInstanceId(reported));
|
||||
}
|
||||
|
||||
/// <summary>Pull roomInstanceId out of either the matchmake reply (which
|
||||
/// wraps it) or the heartbeat's roomInstance object, whatever the casing.</summary>
|
||||
private static string? FindInstanceId(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value.ToString();
|
||||
|
||||
if (property.Value.ValueKind == JsonValueKind.Object
|
||||
&& FindInstanceId(property.Value) is { } nested)
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private HttpClient Client(string host)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Private-room semantics:
|
||||
/// 1. A freshly cloned room is PRIVATE (Accessibility=0) regardless of
|
||||
/// the source's visibility — the owner publishes explicitly.
|
||||
/// 2. A private room has exactly ONE instance per sub-room: every
|
||||
/// JoinMode (plain matchmake / new public / new private) funnels
|
||||
/// into the same deterministic invite-gated instance instead of
|
||||
/// nonce-forking parallel copies.
|
||||
/// 3. Players who are neither the creator nor role-holders/invitees
|
||||
/// are turned away with ErrorCode=4 (indistinguishable from a
|
||||
/// nonexistent room, so private rooms don't leak).
|
||||
/// </summary>
|
||||
public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PrivateRoomInstanceTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_is_private_by_default()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var sourceId = 9_400_100L;
|
||||
await SeedRoomAsync(sourceId, creatorId: owner.PlayerId, accessibility: 1);
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
using var resp = await roomsClient.PostAsync(
|
||||
$"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"PrivClone_{Guid.NewGuid():N}"[..24] }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64();
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var clone = await db.Rooms.SingleAsync(r => r.Id == cloneId);
|
||||
Assert.Equal(0, clone.Accessibility); // private by default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Private_room_gets_one_instance_per_subroom_for_all_join_modes()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_400_200L;
|
||||
await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0);
|
||||
|
||||
using var matchClient = ApiClient(owner, subdomain: "match");
|
||||
|
||||
var joins = new List<(long InstanceId, string Photon, bool IsPrivate)>();
|
||||
foreach (var joinMode in new[] { 0, 1, 2, 0 })
|
||||
{
|
||||
using var resp = await matchClient.PostAsync(
|
||||
$"/matchmake/room/{roomId}",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["JoinMode"] = joinMode.ToString() }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"JoinMode={joinMode} -> {(int)resp.StatusCode}: {text}");
|
||||
using var json = JsonDocument.Parse(text);
|
||||
Assert.Equal(0, json.RootElement.GetProperty("errorCode").GetInt32());
|
||||
var inst = json.RootElement.GetProperty("roomInstance");
|
||||
joins.Add((
|
||||
inst.GetProperty("roomInstanceId").GetInt64(),
|
||||
inst.GetProperty("photonRoomId").GetString()!,
|
||||
inst.GetProperty("isPrivate").GetBoolean()));
|
||||
}
|
||||
|
||||
// Every join mode must land in the SAME single instance.
|
||||
Assert.Single(joins.Select(j => j.InstanceId).Distinct());
|
||||
Assert.Single(joins.Select(j => j.Photon).Distinct());
|
||||
Assert.All(joins, j => Assert.True(j.IsPrivate, "private room instances must be invite-gated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Private_room_turns_away_uninvited_players()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
var stranger = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_400_300L;
|
||||
await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0);
|
||||
|
||||
using var strangerClient = ApiClient(stranger, subdomain: "match");
|
||||
using var resp = await strangerClient.PostAsync(
|
||||
$"/matchmake/room/{roomId}",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["JoinMode"] = "0" }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
// 4 = RoomDoesNotExist — private rooms are invisible, not "forbidden".
|
||||
Assert.Equal(4, json.RootElement.GetProperty("errorCode").GetInt32());
|
||||
}
|
||||
|
||||
private async Task SeedRoomAsync(long roomId, long creatorId, int accessibility)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (await db.Rooms.AnyAsync(r => r.Id == roomId)) return;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"PrivRoom_{roomId}",
|
||||
Description = "private-room test",
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = accessibility,
|
||||
CloningAllowed = true,
|
||||
LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = 0,
|
||||
Name = "Home",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The end-of-quest reward chest is the gifts/generate + gifts/consume
|
||||
/// flow. This covers the load-bearing new behaviour: a gift stamped with
|
||||
/// a <c>SourceStoreItemId</c> (how quest chests are built) grants exactly
|
||||
/// that store item to the player's inventory when the box is opened,
|
||||
/// regardless of item kind. (The room-keyed pick itself is data-driven
|
||||
/// via data/quest_rewards.json and exercised once real per-quest lists
|
||||
/// are configured.)
|
||||
/// </summary>
|
||||
public sealed class QuestRewardChestTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public QuestRewardChestTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Consuming_quest_chest_grants_backing_item()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long giftId;
|
||||
string wardrobeGuid;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
|
||||
wardrobeGuid = item.Slug["wardrobe-".Length..];
|
||||
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = player.PlayerId,
|
||||
AvatarItemType = 0,
|
||||
AvatarItemDescOrHairDyeDesc = wardrobeGuid + ",,,",
|
||||
Message = "You earned it!",
|
||||
SourceStoreItemId = item.Id,
|
||||
IsValid = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
giftId = gift.Id;
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
using var consume = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
|
||||
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
consume.EnsureSuccessStatusCode();
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == player.PlayerId);
|
||||
Assert.NotNull(avatar);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(avatar!.InventoryJson) ?? new();
|
||||
Assert.Contains(wardrobeGuid, inv);
|
||||
|
||||
var gift = await db.GiftPackages.FirstAsync(g => g.Id == giftId);
|
||||
Assert.True(gift.Consumed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consuming_is_idempotent_and_does_not_double_grant()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long giftId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = player.PlayerId,
|
||||
AvatarItemType = 0,
|
||||
AvatarItemDescOrHairDyeDesc = item.Slug["wardrobe-".Length..] + ",,,",
|
||||
SourceStoreItemId = item.Id,
|
||||
IsValid = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
giftId = gift.Id;
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
using var r = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
|
||||
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
r.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
// The wardrobe guid appears exactly once despite two consumes.
|
||||
Assert.Equal(inv.Distinct().Count(), inv.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
using DorkNet.Server.Services;
|
||||
using Google.Protobuf;
|
||||
using RecRoom.Protobuf;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A modern RecNet export's subroom save stamps PersistedRoomData
|
||||
/// versions past what the March-2023 client knows (field 1
|
||||
/// DEPRECATED_RoomPersistenceVersion, max 38; field 30
|
||||
/// PersistedRoomVersion, max 19 = V19February23BetaRelease). The client
|
||||
/// rejects the whole room with its "update Rec Room to visit this room"
|
||||
/// gate — the ShibuyaCrossing import (Sep-2025 save, version=131) is the
|
||||
/// canonical repro. The CDN serve path clamps both varints down; these
|
||||
/// tests pin the rewrite to exactly those two fields.
|
||||
/// </summary>
|
||||
public sealed class RoomBlobVersionClampTests
|
||||
{
|
||||
private static byte[] BuildModernBlob(int deprecatedVersion, int version)
|
||||
{
|
||||
// Emit the wire shape a modern save actually has: version fields
|
||||
// plus payload fields the 2023 schema does and doesn't know about.
|
||||
using var ms = new MemoryStream();
|
||||
var output = new CodedOutputStream(ms);
|
||||
output.WriteTag(1, WireFormat.WireType.Varint);
|
||||
output.WriteInt32(deprecatedVersion);
|
||||
// Field 2: persistence_views payload (opaque here — the clamp
|
||||
// must copy it verbatim).
|
||||
output.WriteTag(2, WireFormat.WireType.LengthDelimited);
|
||||
output.WriteBytes(ByteString.CopyFrom(0x0A, 0x03, 0x01, 0x02, 0x03));
|
||||
// Field 6: sub_room_id.
|
||||
output.WriteTag(6, WireFormat.WireType.Varint);
|
||||
output.WriteInt64(19589779);
|
||||
output.WriteTag(30, WireFormat.WireType.Varint);
|
||||
output.WriteInt32(version);
|
||||
// Field 37: unknown-to-2023 varint field from newer schemas.
|
||||
output.WriteTag(37, WireFormat.WireType.Varint);
|
||||
output.WriteInt32(1);
|
||||
output.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clamps_modern_versions_to_2023_maxima()
|
||||
{
|
||||
var input = BuildModernBlob(deprecatedVersion: 38, version: 131);
|
||||
|
||||
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
Assert.True(changed);
|
||||
var msg = PersistedRoomData.Parser.ParseFrom(bytes);
|
||||
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)msg.Version);
|
||||
Assert.Equal(DEPRECATED_RoomPersistenceVersion.LatestRoomPersistenceVersion, msg.DEPRECATEDVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preserves_every_non_version_byte()
|
||||
{
|
||||
var input = BuildModernBlob(deprecatedVersion: 38, version: 131);
|
||||
|
||||
var (bytes, _) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
// version=131 is a two-byte varint, 16 is one byte — the output
|
||||
// shrinks by exactly that and matches the input everywhere else.
|
||||
Assert.Equal(input.Length - 1, bytes.Length);
|
||||
var expected = BuildModernBlob(
|
||||
deprecatedVersion: 38,
|
||||
version: RoomDataBlobService.Client2023MaxPersistedRoomVersion);
|
||||
Assert.Equal(expected, bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blob_already_at_2023_versions_passes_through_unchanged()
|
||||
{
|
||||
var input = BuildModernBlob(
|
||||
deprecatedVersion: 38,
|
||||
version: RoomDataBlobService.Client2023MaxPersistedRoomVersion);
|
||||
|
||||
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Same(input, bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_input_is_returned_verbatim()
|
||||
{
|
||||
var input = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01 };
|
||||
|
||||
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Same(input, bytes);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 play-menu browse sources. The client's room-discovery URL
|
||||
/// builder (IBEOONPEELF/GGDPFFDEDKN) requests
|
||||
/// <c>rooms/search_rooms/{query}&skip={skip}&take={take}</c> —
|
||||
/// note the paging pairs live INSIDE the final path segment, and the
|
||||
/// whole family is prefixed with <c>rooms/</c> on the rooms host.
|
||||
/// The room photo gallery sends <c>sort</c>/<c>filter</c> as enum
|
||||
/// names (<c>CheerCount_Desc</c>, <c>PublicOnly</c>), which must not
|
||||
/// trip int model binding.
|
||||
/// </summary>
|
||||
public sealed class RoomBrowse2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomBrowse2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Search_rooms_2023_path_with_embedded_paging_returns_matches()
|
||||
{
|
||||
using var setupClient = Client();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var marker = $"Zz{Guid.NewGuid():N}"[..14];
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = 9_300_000 + Random.Shared.Next(1, 99_999),
|
||||
Name = $"Search{marker}",
|
||||
Description = "2023 search test room",
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var roomsClient = Client(owner, subdomain: "rooms");
|
||||
using var response = await roomsClient.GetAsync($"/rooms/search_rooms/{marker}&skip=0&take=20");
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var names = json.RootElement.EnumerateArray()
|
||||
.Select(e => e.GetProperty("Name").GetString() ?? string.Empty)
|
||||
.ToArray();
|
||||
Assert.Contains(names, n => n.Contains(marker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Room_images_accept_2023_enum_name_sort_and_filter()
|
||||
{
|
||||
using var setupClient = Client();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
using var apiClient = Client(player);
|
||||
using var response = await apiClient.GetAsync(
|
||||
"/api/images/v4/room/116?sort=CheerCount_Desc&filter=PublicOnly&take=8&skip=0");
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
|
||||
}
|
||||
|
||||
private HttpClient Client(GameClientSession? session = null, string subdomain = "api")
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and deserialises the
|
||||
/// reply into <c>FGCPNAACHIK</c> — the SAME type <c>GET rooms/{id}</c> returns
|
||||
/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
|
||||
/// <c>Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
|
||||
/// with the route literal on :4802).
|
||||
///
|
||||
/// So the two responses must be shape-identical. When they drift, the server
|
||||
/// still answers 200 and the client throws while reading it — and because the
|
||||
/// clone promise's exception is never observed, it surfaces from the finalizer
|
||||
/// thread seconds later as a bare "Failed to copy room: Exception of type
|
||||
/// '...' was thrown", with nothing in the server log to show for it. That is
|
||||
/// exactly how this failed in the wild: the room WAS created, and the client
|
||||
/// simply never registered it.
|
||||
/// </summary>
|
||||
public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomCloneShapeTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_response_matches_the_get_by_id_shape()
|
||||
{
|
||||
using var client = Client();
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
var sourceId = 9_400_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = sourceId,
|
||||
Name = $"CloneSrc{Guid.NewGuid():N}"[..20],
|
||||
CreatorPlayerId = session.PlayerId,
|
||||
CloningAllowed = true,
|
||||
});
|
||||
// Give the source a real sub-room. Without one, BOTH responses fall
|
||||
// back to the synthesised default sub-room and the comparison never
|
||||
// exercises the branch a real room (like the seeded RecCenter the
|
||||
// room-creation picker clones) actually takes.
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = sourceId,
|
||||
Name = "Home",
|
||||
OrderIndex = 0,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
|
||||
var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||
|
||||
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = expected.Except(actual).Order(StringComparer.Ordinal).ToList();
|
||||
|
||||
// The reader walks the nested sub-room list too, so a key missing there
|
||||
// breaks it just as hard as one missing at the top level.
|
||||
foreach (var list in new[] { "SubRooms", "Scenes" })
|
||||
{
|
||||
if (!getBody.TryGetProperty(list, out var expectedList) ||
|
||||
expectedList.ValueKind != JsonValueKind.Array ||
|
||||
expectedList.GetArrayLength() == 0) continue;
|
||||
|
||||
Assert.True(cloneBody.TryGetProperty(list, out var actualList)
|
||||
&& actualList.ValueKind == JsonValueKind.Array,
|
||||
$"clone response has no {list} array while get-by-id does");
|
||||
Assert.True(actualList.GetArrayLength() > 0,
|
||||
$"clone response has an EMPTY {list} array while get-by-id returns "
|
||||
+ $"{expectedList.GetArrayLength()}; the client's reader requires at least one entry");
|
||||
|
||||
var expectedKeys = expectedList[0].EnumerateObject().Select(p => p.Name);
|
||||
var actualKeys = actualList[0].EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
missing.AddRange(expectedKeys.Where(k => !actualKeys.Contains(k)).Select(k => $"{list}[0].{k}"));
|
||||
}
|
||||
Assert.True(missing.Count == 0,
|
||||
$"""
|
||||
The clone response is missing {missing.Count} key(s) that get-by-id
|
||||
returns. Both deserialize into FGCPNAACHIK, so the client throws while
|
||||
reading the clone and silently never registers the new room:
|
||||
|
||||
{string.Join(Environment.NewLine, missing)}
|
||||
|
||||
clone keys: {string.Join(", ", actual.Order(StringComparer.Ordinal))}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>A clone must own its save data. Cloning used to be a shallow
|
||||
/// copy — the new room's scenes kept pointing at the SOURCE's blob until
|
||||
/// someone saved over them — so every copy of a room shared one object and
|
||||
/// cloning RecCenter produced rooms whose data blob was literally
|
||||
/// <c>room_100_v1.dat</c>. A clone that references another room's blob is
|
||||
/// the bug, whatever the name.</summary>
|
||||
[Fact]
|
||||
public async Task Clone_does_not_reference_the_source_rooms_blob()
|
||||
{
|
||||
// The suite runs with S3 unconfigured, where there is nothing to copy
|
||||
// and the clone deliberately keeps the shallow reference. Swap in a
|
||||
// real (in-memory) store so this exercises the copy path itself.
|
||||
var store = new InMemoryObjectStorage();
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IObjectStorage>();
|
||||
services.AddSingleton<IObjectStorage>(store);
|
||||
}));
|
||||
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
var sourceId = 9_500_000 + Random.Shared.Next(1, 99_999);
|
||||
var sourceBlob = $"room_{sourceId}_v1.dat";
|
||||
var sourceBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob);
|
||||
await store.PutAsync(srcBucket, srcKey, sourceBytes, "application/octet-stream");
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = sourceId,
|
||||
Name = $"BlobSrc{Guid.NewGuid():N}"[..20],
|
||||
CreatorPlayerId = session.PlayerId,
|
||||
CloningAllowed = true,
|
||||
CurrentDataBlobName = sourceBlob,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = sourceId,
|
||||
Name = "Home",
|
||||
OrderIndex = 0,
|
||||
DataBlobName = sourceBlob,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.FirstAsync(r => r.Id == cloneId);
|
||||
var scenes = await db.RoomScenes.Where(s => s.RoomId == cloneId).ToListAsync();
|
||||
|
||||
Assert.False(room.CurrentDataBlobName == sourceBlob,
|
||||
$"the clone's room row still points at the source's blob ({sourceBlob})");
|
||||
foreach (var scene in scenes)
|
||||
Assert.False(scene.DataBlobName == sourceBlob,
|
||||
$"cloned sub-room {scene.OrderIndex} still points at the source's blob ({sourceBlob})");
|
||||
|
||||
// It must be a real copy, not just a cleared reference — the point
|
||||
// is that the player's content comes along.
|
||||
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName),
|
||||
"the clone has no blob at all; the source's data should have been copied, not dropped");
|
||||
var (dstBucket, dstKey) = BlobRouter.Route(room.CurrentDataBlobName);
|
||||
Assert.Equal(sourceBytes, await store.GetAsync(dstBucket, dstKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal in-memory <see cref="IObjectStorage"/>. The suite runs
|
||||
/// with S3 unconfigured, so without this the clone's copy path short-circuits
|
||||
/// and never gets exercised.</summary>
|
||||
private sealed class InMemoryObjectStorage : IObjectStorage
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
public bool IsS3Configured => true;
|
||||
|
||||
public Task<long> PutAsync(
|
||||
string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default)
|
||||
{
|
||||
_objects[$"{bucket}/{key}"] = bytes;
|
||||
return Task.FromResult((long)bytes.Length);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetAsync(string bucket, string key, CancellationToken ct = default) =>
|
||||
Task.FromResult(_objects.TryGetValue($"{bucket}/{key}", out var bytes) ? bytes : null);
|
||||
|
||||
public Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct = default) =>
|
||||
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ReadJsonAsync(
|
||||
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path) { Content = content };
|
||||
using var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"{method} {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage of the 2023 client's in-room shop
|
||||
/// (api/roomconsumables/v1) using the exact request/response wire shapes
|
||||
/// the 2023.03.21 client serialises (verified against the RecNet.Runtime
|
||||
/// formatters in the ISIL dump). The critical regression guard: every
|
||||
/// inventory row returned by <c>/room/{id}/me</c> must carry a non-null
|
||||
/// <c>Consumable</c> desc — RoomConsumablesManager NREs on room join
|
||||
/// otherwise.
|
||||
/// </summary>
|
||||
public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomConsumablesShopTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Full_shop_loop_create_purchase_inventory_consume_delete()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
var buyer = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_200_001L;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"ShopRoom_{Guid.NewGuid():N}"[..24],
|
||||
Description = "shop test room",
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
State = 0,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "community",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var level = scope.ServiceProvider.GetRequiredService<LevelService>();
|
||||
await level.GrantCurrencyAsync(buyer.PlayerId, 2, 100, "test-seed");
|
||||
}
|
||||
|
||||
using var ownerClient = ApiClient(owner);
|
||||
using var buyerClient = ApiClient(buyer);
|
||||
|
||||
// ── Create (client body = the desc DTO itself) ───────────────────
|
||||
var created = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
||||
{
|
||||
"RoomId": {{roomId}},
|
||||
"Name": "Cola",
|
||||
"Description": "A refreshing test beverage",
|
||||
"ImageName": "",
|
||||
"PriceAndCurrency": { "Price": 10, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(0, created.GetProperty("Status").GetInt32());
|
||||
var consumableId = created.GetProperty("Consumable").GetProperty("RoomConsumableId").GetGuid();
|
||||
Assert.NotEqual(Guid.Empty, consumableId);
|
||||
Assert.Equal(roomId, created.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
|
||||
|
||||
// ── Non-owner cannot create in someone else's room ───────────────
|
||||
var forbidden = await PostJsonAsync(buyerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
||||
{ "RoomId": {{roomId}}, "Name": "Hax", "Description": "", "ImageName": "",
|
||||
"PriceAndCurrency": { "Price": 1, "CurrencyId": null } }
|
||||
""");
|
||||
Assert.Equal(7, forbidden.GetProperty("Status").GetInt32()); // PlayerDoesntHavePermission
|
||||
|
||||
// ── Room catalog ─────────────────────────────────────────────────
|
||||
var catalog = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
|
||||
Assert.Equal(1, catalog.GetArrayLength());
|
||||
// Responses carry the price FLAT. The client's consumable formatter
|
||||
// (RecNet.Runtime/FCIBLPCOODP) reads RoomConsumableId/RoomId/Name/
|
||||
// Description/ImageName/Price/PurchaseCurrencyId/ModifiedAt and has no
|
||||
// "PriceAndCurrency" key at all — only REQUEST bodies nest it.
|
||||
Assert.Equal(10, catalog[0].GetProperty("Price").GetInt64());
|
||||
|
||||
// ── Purchase at the wrong price is rejected ──────────────────────
|
||||
var codeA = Guid.NewGuid();
|
||||
var badPrice = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
|
||||
{
|
||||
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
|
||||
"ExpectedPriceAndCurrency": { "Price": 5, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
// OperationResult is an OBJECT { Status, InventoryItem } — the base type
|
||||
// OKHNOPLBOFP holds a single BGHBAILNNJJ, which itself exposes the status
|
||||
// enum plus the inventory row. The scalar token-operation enum lives in
|
||||
// BalanceUpdateResult (Nullable<CABBDKFODEC>) on MJKFEHPIDME.
|
||||
Assert.Equal(6, badPrice.GetProperty("BalanceUpdateResult").GetInt32()); // RequestedPriceDoesNotMatch
|
||||
Assert.Equal(38, badPrice.GetProperty("OperationResult").GetProperty("Status").GetInt32());
|
||||
var balanceBefore = badPrice.GetProperty("TokenBalanceResponse").GetProperty("Balance").GetInt64();
|
||||
|
||||
// ── Purchase (client stores NewConcurrencyCode locally) ──────────
|
||||
var purchase = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
|
||||
{
|
||||
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
|
||||
"ExpectedPriceAndCurrency": { "Price": 10, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(0, purchase.GetProperty("OperationResult").GetProperty("Status").GetInt32());
|
||||
var tokenBalance = purchase.GetProperty("TokenBalanceResponse");
|
||||
Assert.Equal(2, tokenBalance.GetProperty("CurrencyType").GetInt32());
|
||||
Assert.Equal(balanceBefore - 10, tokenBalance.GetProperty("Balance").GetInt64());
|
||||
|
||||
// ── Inventory on room join: Consumable must NEVER be null ────────
|
||||
var inventory = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}/me");
|
||||
Assert.Equal(1, inventory.GetArrayLength());
|
||||
var row = inventory[0];
|
||||
Assert.Equal(consumableId, row.GetProperty("RoomConsumableId").GetGuid());
|
||||
Assert.Equal((int)buyer.PlayerId, row.GetProperty("AccountId").GetInt32());
|
||||
Assert.Equal(1, row.GetProperty("Count").GetInt32());
|
||||
Assert.Equal(codeA, row.GetProperty("ConcurrencyCode").GetGuid());
|
||||
Assert.Equal(JsonValueKind.Object, row.GetProperty("Consumable").ValueKind);
|
||||
Assert.Equal(roomId, row.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
|
||||
|
||||
// ── isOwned: a non-creator holds stock ───────────────────────────
|
||||
using var isOwnedResponse = await ownerClient.GetAsync(
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/isOwned");
|
||||
Assert.Equal("true", await isOwnedResponse.Content.ReadAsStringAsync());
|
||||
|
||||
// ── Consume with a stale concurrency code resyncs the client ─────
|
||||
var codeB = Guid.NewGuid();
|
||||
var staleConsume = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
||||
{ "CurrentConcurrencyCode": "{{Guid.NewGuid()}}", "NewConcurrencyCode": "{{codeB}}" }
|
||||
""");
|
||||
Assert.Equal(32, staleConsume.GetProperty("Status").GetInt32()); // ConcurrencyCodeMismatch
|
||||
Assert.Equal(1, staleConsume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
|
||||
|
||||
// ── Consume with the right code decrements and rotates the code ──
|
||||
var consume = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
||||
{ "CurrentConcurrencyCode": "{{codeA}}", "NewConcurrencyCode": "{{codeB}}" }
|
||||
""");
|
||||
Assert.Equal(0, consume.GetProperty("Status").GetInt32());
|
||||
Assert.Equal(0, consume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
|
||||
Assert.Equal(codeB, consume.GetProperty("InventoryItem").GetProperty("ConcurrencyCode").GetGuid());
|
||||
|
||||
// ── Consuming an empty stack is refused ──────────────────────────
|
||||
var emptyConsume = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
||||
{ "CurrentConcurrencyCode": "{{codeB}}", "NewConcurrencyCode": "{{Guid.NewGuid()}}" }
|
||||
""");
|
||||
Assert.Equal(33, emptyConsume.GetProperty("Status").GetInt32()); // PlayerDoesNotOwnConsumable
|
||||
|
||||
// ── Update via POST with RoomConsumableId in the body ────────────
|
||||
var updated = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
||||
{
|
||||
"RoomConsumableId": "{{consumableId}}",
|
||||
"RoomId": {{roomId}},
|
||||
"Name": "Diet Cola",
|
||||
"Description": "Now with fewer tokens",
|
||||
"ImageName": "",
|
||||
"PriceAndCurrency": { "Price": 5, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(0, updated.GetProperty("Status").GetInt32());
|
||||
Assert.Equal("Diet Cola", updated.GetProperty("Consumable").GetProperty("Name").GetString());
|
||||
Assert.Equal(5, updated.GetProperty("Consumable").GetProperty("Price").GetInt64());
|
||||
|
||||
// ── Delete: buyer refused, owner succeeds, catalog empties ───────
|
||||
using var buyerDelete = await buyerClient.DeleteAsync(
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
|
||||
Assert.Equal("7", await buyerDelete.Content.ReadAsStringAsync()); // PlayerDoesntHavePermission
|
||||
|
||||
using var ownerDelete = await ownerClient.DeleteAsync(
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
|
||||
Assert.Equal("0", await ownerDelete.Content.ReadAsStringAsync());
|
||||
|
||||
var emptied = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
|
||||
Assert.Equal(0, emptied.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unprefixed_2023_commerce_probes_respond()
|
||||
{
|
||||
// The 2023 client calls these WITHOUT the api/ prefix on startup;
|
||||
// a 404 here surfaces as "CleanupPendingTransactions failed" +
|
||||
// unobserved-HTTP-404 crash reports in Player.log.
|
||||
using var client = ApiClient();
|
||||
|
||||
using var cleanup = await client.PostAsync("/purchase/v1/cleanuppending",
|
||||
new StringContent("", Encoding.UTF8, "application/x-www-form-urlencoded"));
|
||||
Assert.True(cleanup.IsSuccessStatusCode);
|
||||
|
||||
using var hasSpent = await client.GetAsync("/purchase/v1/hasspentmoney");
|
||||
Assert.Equal("false", await hasSpent.Content.ReadAsStringAsync());
|
||||
|
||||
var bundles = await GetJsonAsync(client, "/reminder/currentTokenBundles/v2");
|
||||
Assert.Equal(JsonValueKind.Array, bundles.ValueKind);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path, string json)
|
||||
{
|
||||
using var response = await client.PostAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 room-save flow (captured from the 2023.03.21 client against dev,
|
||||
/// 2026-07-07). The client uploads two blobs to storage /upload —
|
||||
/// FileType=6 room metadata, then FileType=1 scene save — and commits
|
||||
/// them with a JSON POST to rooms/{id}/subrooms/{id}/data:
|
||||
/// { "UnityAssetId": null,
|
||||
/// "RoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null },
|
||||
/// "SubRoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null } }
|
||||
/// SubRoomData.Filename is the scene blob and must become the room's
|
||||
/// CurrentDataBlobName.
|
||||
/// </summary>
|
||||
public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomSave2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task SubRoomData_accepts_2023_nested_json_and_commits_blob()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_200_001L;
|
||||
var blobName = $"room_{roomId}_v1_{Guid.NewGuid():N}.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
|
||||
{
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Save2023_{Guid.NewGuid():N}"[..24],
|
||||
Description = "2023 save flow test room",
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.Rooms.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(r => r.CreatorPlayerId, owner.PlayerId));
|
||||
}
|
||||
}
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
var body = new
|
||||
{
|
||||
UnityAssetId = (long?)null,
|
||||
RoomData = new { Filename = "roommeta_test.bin", Hash = (string?)null, OwnershipProof = (string?)null },
|
||||
SubRoomData = new { Filename = blobName, Hash = (string?)null, OwnershipProof = (string?)null },
|
||||
};
|
||||
using var response = await roomsClient.PostAsync(
|
||||
$"/rooms/{roomId}/subrooms/0/data",
|
||||
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
|
||||
|
||||
using var json = JsonDocument.Parse(responseText);
|
||||
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), responseText);
|
||||
|
||||
// 2023 commit-response contract (NEOPBOMGIOG): value carries a
|
||||
// Room and a SubRoomDataSave. The Room reuses the exact GET
|
||||
// rooms/{id} shape (BuildRoomServerDetails) — do NOT bolt extra
|
||||
// keys onto it: the room-details mapper is strict about value
|
||||
// shapes and an invented RankingContext object broke every
|
||||
// room-details parse ("Malformed Response"). See befd590 revert.
|
||||
var value = json.RootElement.GetProperty("value");
|
||||
var roomDto = value.GetProperty("Room");
|
||||
Assert.Equal(JsonValueKind.Object, roomDto.ValueKind);
|
||||
foreach (var key in new[] { "RoomId", "SubRooms", "Roles", "Tags", "Stats" })
|
||||
Assert.True(roomDto.TryGetProperty(key, out _), $"Room missing '{key}': {responseText}");
|
||||
|
||||
var save = value.GetProperty("SubRoomDataSave");
|
||||
Assert.Equal(blobName, save.GetProperty("DataBlob").GetString());
|
||||
foreach (var key in new[] { "SubRoomDataSaveId", "SubRoomId", "UnityAssetId", "SavedByAccountId", "CreatedAt" })
|
||||
Assert.True(save.TryGetProperty(key, out _), $"SubRoomDataSave missing '{key}': {responseText}");
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.AsNoTracking().SingleAsync(r => r.Id == roomId);
|
||||
Assert.Equal(blobName, room.CurrentDataBlobName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_fileType6_room_metadata_is_stored_not_stubbed()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
using var storageClient = ApiClient(player, subdomain: "storage");
|
||||
using var form = new MultipartFormDataContent("BestHTTP_HTTPMultiPartForm_TEST");
|
||||
var file = new ByteArrayContent(new byte[] { 0x0A, 0x02, 0x08, 0x01 });
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
form.Add(file, "File", "file.bin");
|
||||
form.Add(new StringContent("6"), "FileType");
|
||||
|
||||
using var response = await storageClient.PostAsync("/upload", form);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
|
||||
|
||||
using var json = JsonDocument.Parse(responseText);
|
||||
var filename = json.RootElement.GetProperty("filename").GetString();
|
||||
Assert.NotNull(filename);
|
||||
Assert.StartsWith("roommeta_", filename);
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.True(
|
||||
await db.RoomDataBlobs.AnyAsync(b => b.BlobName == filename),
|
||||
$"expected a RoomDataBlobs row for {filename}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression: cloning a room — and then cloning the CLONE — must both
|
||||
/// succeed and return the full room-details shape (FGCPNAACHIK). Previously
|
||||
/// the handler relied on DB auto-increment for the room id AND the copied
|
||||
/// scene ids, which collide with the seeded id range once the identity
|
||||
/// sequence lags → the clone POST 500s and the client boots the player to
|
||||
/// their dorm ("Failed to copy room: Failed to clone room").
|
||||
///
|
||||
/// The first clone goes through the <c>roomserver/</c>-prefixed route:
|
||||
/// the 2023 in-room "copy room" flow (RecNet.Runtime NLDBPDCNNCF) sends
|
||||
/// that prefix like every other room mutation, and the bare-only route
|
||||
/// 404'd with an empty body ("Failed to copy room: Exception of type
|
||||
/// '…' was thrown").
|
||||
///
|
||||
/// Blob rules: cloning a FIRST-PARTY template (AG + system-owned) must
|
||||
/// give the clone a FRESH empty blob — the template's blob is a MakerPen
|
||||
/// overlay against the shared baked scene, not the clone's content.
|
||||
/// Cloning a USER-owned room (even AG-flagged) must carry its blob so
|
||||
/// "copy room" keeps the player's edits.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Cloning_a_room_and_then_the_clone_both_succeed()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_300_777L;
|
||||
var templateBlob = $"room_{roomId}_v1.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
|
||||
{
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"CloneSrc_{Guid.NewGuid():N}"[..20],
|
||||
Description = "clone source",
|
||||
// A first-party RRO/AG base room (RecCenter-like):
|
||||
// system-owned, CloningAllowed=false, yet the base-room
|
||||
// picker must still be able to clone it. Its blob is a
|
||||
// MakerPen overlay that must NOT leak into clones.
|
||||
CreatorPlayerId = PlayerService.SystemAccountId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
CloningAllowed = false,
|
||||
CurrentDataBlobName = templateBlob,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = 0,
|
||||
Name = "Main",
|
||||
DataBlobName = templateBlob,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
|
||||
// First clone: template → player copy, via the roomserver/ prefix the
|
||||
// real client uses.
|
||||
var firstCloneId = await CloneAndAssertAsync(
|
||||
roomsClient, roomId, "FirstClone", pathPrefix: "/roomserver");
|
||||
Assert.NotEqual(roomId, firstCloneId);
|
||||
// A clone of a first-party template used to be left with NO blob, on
|
||||
// the reasoning that its content is baked client geometry. That left
|
||||
// the copy with nothing to load: the CDN synthesises a ~1.8 KB stub for
|
||||
// a name it has nothing stored under, and the client fetches exactly
|
||||
// that through GetRoomData ("/room/{blob}") while copying, then rejects
|
||||
// it. Clones that carried real content joined fine.
|
||||
//
|
||||
// The content now comes across. Sharing the SOURCE's object is still
|
||||
// wrong, but that is prevented by copying it under the clone's own name
|
||||
// rather than by having no blob at all — see
|
||||
// RoomCloneShapeTests.Clone_does_not_reference_the_source_rooms_blob,
|
||||
// which runs with a real object store. This suite has none, so the
|
||||
// copy deliberately falls back to keeping the reference and only the
|
||||
// "has content" half is checkable here.
|
||||
await AssertBlobsAsync(firstCloneId, expectEmpty: false,
|
||||
"a clone of a first-party template must carry its content");
|
||||
|
||||
// Simulate the player saving MakerPen edits into their clone.
|
||||
var editedBlob = $"room_{firstCloneId}_edit_{Guid.NewGuid():N}.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.SingleAsync(r => r.Id == firstCloneId);
|
||||
room.CurrentDataBlobName = editedBlob;
|
||||
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == firstCloneId);
|
||||
scene.DataBlobName = editedBlob;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// The regression: cloning the clone (bare path still works too).
|
||||
// The source is user-owned — even though it inherited IsAGRoom, the
|
||||
// player's edits must carry over.
|
||||
var secondCloneId = await CloneAndAssertAsync(roomsClient, firstCloneId, "SecondClone");
|
||||
Assert.NotEqual(firstCloneId, secondCloneId);
|
||||
await AssertBlobsAsync(secondCloneId, expectEmpty: false,
|
||||
"copying a user-owned room must keep its blob (player edits)");
|
||||
}
|
||||
|
||||
private async Task AssertBlobsAsync(long roomId, bool expectEmpty, string why)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.SingleAsync(r => r.Id == roomId);
|
||||
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == roomId);
|
||||
if (expectEmpty)
|
||||
{
|
||||
Assert.True(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was '{room.CurrentDataBlobName}'");
|
||||
Assert.True(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was '{scene.DataBlobName}'");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was empty");
|
||||
Assert.False(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was empty");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<long> CloneAndAssertAsync(
|
||||
HttpClient client, long sourceId, string name, string pathPrefix = "")
|
||||
{
|
||||
// The real 2023 client posts the name as x-www-form-urlencoded
|
||||
// (`name=...`), NOT JSON — testing with JSON would miss the 415 that
|
||||
// a [FromBody] handler returns for a form POST.
|
||||
using var resp = await client.PostAsync(
|
||||
$"{pathPrefix}/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = name }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var root = json.RootElement;
|
||||
// Full room-details shape (FGCPNAACHIK), not a status wrapper.
|
||||
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
|
||||
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
|
||||
return root.GetProperty("RoomId").GetInt64();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression: <c>GET rooms/{id}/subrooms/{sub}/saves</c> must return the
|
||||
/// paged wrapper <c>{"Results":[…],"TotalResults":N}</c>
|
||||
/// (PagedResultsDTO<SubRoomDataSaveDTO>), NOT a bare array. The 2023
|
||||
/// client's GetSubRoomDataSaves strict reader throws
|
||||
/// <c>expected:'{', actual:'['</c> on an array, which aborts the whole
|
||||
/// calling flow — room clone fails with the message-less "Failed to copy
|
||||
/// room" and the private-instance button silently does nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubRoom_saves_returns_paged_object_not_bare_array()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_300_888L;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
|
||||
{
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"SavesShape_{Guid.NewGuid():N}"[..20],
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
CurrentDataBlobName = $"room_{roomId}_v1.dat",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = 0,
|
||||
Name = "Home",
|
||||
DataBlobName = $"room_{roomId}_v1.dat",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
foreach (var path in new[]
|
||||
{
|
||||
$"/roomserver/rooms/{roomId}/subrooms/0/saves?skip=0&take=10",
|
||||
$"/rooms/{roomId}/subrooms/0/saves",
|
||||
$"/roomserver/rooms/{roomId}/subrooms/0/saves/no_unity_assets?skip=0&take=10",
|
||||
})
|
||||
{
|
||||
using var resp = await roomsClient.GetAsync(path);
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"{path} -> {(int)resp.StatusCode}: {text}");
|
||||
using var json = JsonDocument.Parse(text);
|
||||
Assert.Equal(JsonValueKind.Object, json.RootElement.ValueKind);
|
||||
Assert.Equal(JsonValueKind.Array, json.RootElement.GetProperty("Results").ValueKind);
|
||||
Assert.True(json.RootElement.GetProperty("TotalResults").GetInt64() >= 1, $"TotalResults missing/zero: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class RoomServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToWireRoom_never_emits_empty_image_name()
|
||||
{
|
||||
var room = new RoomEntity
|
||||
{
|
||||
Id = 1001,
|
||||
Name = "CustomRoomWithoutImage",
|
||||
Description = "Custom room",
|
||||
ImageName = "",
|
||||
Accessibility = 1,
|
||||
State = 0,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(RoomService.ToWireRoom(room));
|
||||
using var document = JsonDocument.Parse(json);
|
||||
|
||||
Assert.Equal(RoomService.DefaultRoomImageName, document.RootElement.GetProperty("ImageName").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDisplayImageName_preserves_existing_image_name()
|
||||
{
|
||||
var room = new RoomEntity
|
||||
{
|
||||
Name = "Paintball",
|
||||
ImageName = "custom-thumbnail.png",
|
||||
};
|
||||
|
||||
Assert.Equal("custom-thumbnail.png", RoomService.ResolveDisplayImageName(room));
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class RoomsVisitedByTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomsVisitedByTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Continue_rooms_hide_private_rooms_from_non_owners()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
var visitor = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var publicRoomId = 9_100_001L;
|
||||
var privateRoomId = 9_100_002L;
|
||||
var publicRoomName = $"ContinuePublic_{suffix}";
|
||||
var privateRoomName = $"ContinuePrivate_{suffix}";
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.AddRange(
|
||||
BuildRoom(publicRoomId, publicRoomName, owner.PlayerId, accessibility: 1),
|
||||
BuildRoom(privateRoomId, privateRoomName, owner.PlayerId, accessibility: 0));
|
||||
db.RoomVisits.AddRange(
|
||||
BuildVisit(publicRoomId, visitor.PlayerId, now.AddMinutes(-2)),
|
||||
BuildVisit(privateRoomId, visitor.PlayerId, now.AddMinutes(-1)),
|
||||
BuildVisit(privateRoomId, owner.PlayerId, now));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var visitorClient = ApiClient(visitor);
|
||||
var visitorNames = await GetContinueRoomNamesAsync(visitorClient);
|
||||
|
||||
Assert.Contains(publicRoomName, visitorNames);
|
||||
Assert.DoesNotContain(privateRoomName, visitorNames);
|
||||
|
||||
using var ownerClient = ApiClient(owner);
|
||||
var ownerNames = await GetContinueRoomNamesAsync(ownerClient);
|
||||
|
||||
Assert.Contains(privateRoomName, ownerNames);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<string[]> GetContinueRoomNamesAsync(HttpClient client)
|
||||
{
|
||||
using var response = await client.GetAsync("/rooms/visitedby/me");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync();
|
||||
using var document = await JsonDocument.ParseAsync(body);
|
||||
return document.RootElement
|
||||
.EnumerateArray()
|
||||
.Select(room => room.GetProperty("Name").GetString() ?? string.Empty)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static RoomEntity BuildRoom(long id, string name, long creatorPlayerId, int accessibility) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = name,
|
||||
CreatorPlayerId = creatorPlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
State = 0,
|
||||
Accessibility = accessibility,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
HiddenFromBrowse = false,
|
||||
TagsCsv = "community",
|
||||
};
|
||||
|
||||
private static RoomVisitEntity BuildVisit(long roomId, long playerId, DateTime visitedAt) => new()
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = playerId,
|
||||
FirstVisitAt = visitedAt,
|
||||
LastVisitAt = visitedAt,
|
||||
VisitCount = 1,
|
||||
};
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the two ways a column reaches a database that already exists, because
|
||||
/// getting either one wrong is invisible until a live server starts returning
|
||||
/// 500s.
|
||||
///
|
||||
/// Every other test in this suite runs against a FRESH database, where
|
||||
/// <c>EnsureCreated</c> builds the whole schema straight from the entity model —
|
||||
/// so every column always exists and none of this is exercised. Only an
|
||||
/// *existing* database can be missing a column, and that is exactly what took
|
||||
/// down club home, matchmake/dorm, the presence heartbeat and the chat inbox at
|
||||
/// once: three columns were added to the entity model with an EF migration, but
|
||||
/// the Postgres path never replays migrations, so a deployed database never got
|
||||
/// them.
|
||||
///
|
||||
/// The two mechanisms, and the invariant each test asserts:
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item><b>SQLite</b> applies committed EF migrations via <c>Database.Migrate()</c>.
|
||||
/// So the migration history alone must be able to build the current model.</item>
|
||||
/// <item><b>Postgres</b> is <c>EnsureCreated</c>-only and NEVER replays
|
||||
/// migrations. A database created before a column existed only gets it from an
|
||||
/// idempotent <c>Ensure*</c> ALTER in <see cref="DorkNet.Server.Startup"/>'s
|
||||
/// DatabaseBootstrap. So anything added after the Initial migration must appear
|
||||
/// there too.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class SchemaBootstrapParityTests
|
||||
{
|
||||
/// <summary>Every table and column in the entity model must be reachable on
|
||||
/// a database that already exists — via a committed migration, an
|
||||
/// idempotent <c>Ensure*</c> patch, or both.
|
||||
///
|
||||
/// That union is exactly what boot does: SQLite runs <c>Database.Migrate()</c>
|
||||
/// and then the <c>Ensure*</c> helpers, and Postgres runs the helpers alone.
|
||||
/// A property with NEITHER reaches only brand-new databases, and every
|
||||
/// query touching it throws at runtime on a deployed server.
|
||||
///
|
||||
/// The union is asserted rather than migrations alone because a large amount
|
||||
/// of the schema legitimately predates this rule and lives only in the
|
||||
/// helpers. Tightening that up is a separate cleanup; what must never happen
|
||||
/// is a column covered by nothing.</summary>
|
||||
[Fact]
|
||||
public async Task Every_model_column_reaches_an_existing_database()
|
||||
{
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DorkNetDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var db = new DorkNetDbContext(options);
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
var actual = await ReadSqliteSchemaAsync(connection);
|
||||
var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: false);
|
||||
var missing = new List<string>();
|
||||
|
||||
foreach (var entity in db.Model.GetEntityTypes())
|
||||
{
|
||||
var table = entity.GetTableName();
|
||||
if (string.IsNullOrEmpty(table)) continue;
|
||||
|
||||
var migrated = actual.TryGetValue(table, out var columns);
|
||||
if (!migrated && !patchedTables.Contains(table))
|
||||
{
|
||||
missing.Add($"{table} (whole table)");
|
||||
continue;
|
||||
}
|
||||
// A table the helpers create carries its own columns in the same
|
||||
// CREATE, so only migrated tables need a per-column check.
|
||||
if (!migrated) continue;
|
||||
|
||||
foreach (var property in entity.GetProperties())
|
||||
{
|
||||
var column = property.GetColumnName();
|
||||
if (string.IsNullOrEmpty(column)) continue;
|
||||
if (columns!.Contains(column)) continue;
|
||||
if (patchedColumns.Contains($"{table}.{column}")) continue;
|
||||
missing.Add($"{table}.{column}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
$"""
|
||||
{missing.Count} model table(s)/column(s) are covered by NOTHING.
|
||||
|
||||
They exist on a freshly-created database (EnsureCreated builds the
|
||||
whole model) but never appear on one that already exists, so a
|
||||
deployed server throws "no such column" / "column does not exist"
|
||||
on every request that touches them.
|
||||
|
||||
Add a migration AND an idempotent ALTER in an Ensure*Async helper in
|
||||
DorkNet.Server/Startup/DatabaseBootstrap.cs for:
|
||||
|
||||
{string.Join(Environment.NewLine, missing.Order(StringComparer.Ordinal))}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Every column added AFTER the Initial migration must also be
|
||||
/// added by an idempotent Postgres <c>ALTER</c> in DatabaseBootstrap.
|
||||
///
|
||||
/// Postgres never replays migrations, so a migration alone reaches only
|
||||
/// brand-new databases. A production database created before the column
|
||||
/// existed gets it from the Ensure* helpers or not at all — the exact hole
|
||||
/// that broke matchmake/dorm (PrivateInstances.RoomCode),
|
||||
/// club/home/me (Clubs.ClubChatEnabled) and the chat inbox
|
||||
/// (ChatMessages.ModerationState).</summary>
|
||||
[Fact]
|
||||
public void Post_initial_columns_are_patched_for_existing_postgres_databases()
|
||||
{
|
||||
var migrations = FindRepoDirectory("DorkNet.Server", "Migrations");
|
||||
var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: true);
|
||||
|
||||
// Anything AddColumn'd by a migration other than Initial post-dates the
|
||||
// baseline, so an existing Postgres database will not have it.
|
||||
var gaps = new List<string>();
|
||||
foreach (var file in Directory.GetFiles(migrations, "*.cs"))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(file);
|
||||
if (name.Contains("Designer", StringComparison.Ordinal)) continue;
|
||||
if (name.Contains("Initial", StringComparison.Ordinal)) continue;
|
||||
if (name.Contains("ModelSnapshot", StringComparison.Ordinal)) continue;
|
||||
|
||||
var text = File.ReadAllText(file);
|
||||
foreach (Match m in Regex.Matches(text,
|
||||
@"AddColumn<[^>]+>\(\s*name:\s*""(?<column>\w+)""\s*,\s*table:\s*""(?<table>\w+)""",
|
||||
RegexOptions.Singleline))
|
||||
{
|
||||
var table = m.Groups["table"].Value;
|
||||
var column = m.Groups["column"].Value;
|
||||
if (patchedTables.Contains(table)) continue; // whole table is created by a patch
|
||||
if (patchedColumns.Contains($"{table}.{column}")) continue;
|
||||
gaps.Add($"{table}.{column} (added by {name})");
|
||||
}
|
||||
|
||||
foreach (Match m in Regex.Matches(text,
|
||||
@"CreateTable\(\s*name:\s*""(?<table>\w+)""",
|
||||
RegexOptions.Singleline))
|
||||
{
|
||||
var table = m.Groups["table"].Value;
|
||||
if (!patchedTables.Contains(table))
|
||||
gaps.Add($"{table} (whole table, created by {name})");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(gaps.Count == 0,
|
||||
$"""
|
||||
{gaps.Count} schema change(s) reach new databases only.
|
||||
|
||||
The Postgres path is EnsureCreated-only and never replays
|
||||
migrations, so a migration alone does NOT add these to a database
|
||||
that already exists — a deployed server will throw
|
||||
"column does not exist" on every request that touches them.
|
||||
|
||||
Add an idempotent ALTER/CREATE to an Ensure*Async helper in
|
||||
DorkNet.Server/Startup/DatabaseBootstrap.cs for:
|
||||
|
||||
{string.Join(Environment.NewLine, gaps.Order(StringComparer.Ordinal))}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Tables and columns the <c>Ensure*</c> helpers add by raw SQL.
|
||||
///
|
||||
/// The helpers are private, so this reads their statements out of the source
|
||||
/// rather than executing them. In that source the SQL sits inside verbatim
|
||||
/// strings, so a quoted identifier appears as two literal quote characters
|
||||
/// (<c>""Playlists""</c>).
|
||||
///
|
||||
/// <paramref name="postgresOnly"/> keys off <c>IF NOT EXISTS</c>: the
|
||||
/// Postgres branch uses it, while the SQLite branch omits it and swallows
|
||||
/// the duplicate-column error instead. So requiring it isolates the branch
|
||||
/// that an existing Postgres database actually depends on.</summary>
|
||||
private static (HashSet<string> Tables, HashSet<string> Columns) ReadBootstrapPatches(
|
||||
bool postgresOnly)
|
||||
{
|
||||
var bootstrap = Path.Combine(
|
||||
FindRepoDirectory("DorkNet.Server", "Startup"), "DatabaseBootstrap.cs");
|
||||
Assert.True(File.Exists(bootstrap), $"missing {bootstrap}");
|
||||
var sql = File.ReadAllText(bootstrap);
|
||||
|
||||
// The source mixes both C# string styles, so a quoted identifier shows
|
||||
// up as either two quote characters (verbatim, @"... ""X"" ...") or one
|
||||
// (raw, """... "X" ..."""). Accept either.
|
||||
const string Q = "\"{1,2}";
|
||||
var ifNotExists = postgresOnly ? @"IF\s+NOT\s+EXISTS\s+" : @"(?:IF\s+NOT\s+EXISTS\s+)?";
|
||||
|
||||
var columns = Regex
|
||||
.Matches(sql,
|
||||
$@"ALTER\s+TABLE\s+{Q}(?<table>\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}(?<column>\w+){Q}",
|
||||
RegexOptions.IgnoreCase)
|
||||
.Select(m => $"{m.Groups["table"].Value}.{m.Groups["column"].Value}")
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tables = Regex
|
||||
.Matches(sql,
|
||||
$@"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+{Q}(?<table>\w+){Q}",
|
||||
RegexOptions.IgnoreCase)
|
||||
.Select(m => m.Groups["table"].Value)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Some helpers loop a column-name array through one interpolated ALTER
|
||||
// (EnsureGameRewardColumnsAsync does). The statement alone only shows
|
||||
// "{col}", so pair each such ALTER with the nearest preceding
|
||||
// `new[] { "A", "B" }` and expand it.
|
||||
foreach (Match alter in Regex.Matches(sql,
|
||||
$@"ALTER\s+TABLE\s+{Q}(?<table>\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}\{{(?<var>\w+)\}}{Q}",
|
||||
RegexOptions.IgnoreCase))
|
||||
{
|
||||
var loop = Regex.Matches(
|
||||
sql[..alter.Index],
|
||||
$@"foreach\s*\(\s*var\s+{Regex.Escape(alter.Groups["var"].Value)}\s+in\s+new\[\]\s*\{{(?<items>[^}}]*)\}}")
|
||||
.Cast<Match>()
|
||||
.LastOrDefault();
|
||||
if (loop is null) continue;
|
||||
|
||||
foreach (Match item in Regex.Matches(loop.Groups["items"].Value, "\"(?<name>\\w+)\""))
|
||||
columns.Add($"{alter.Groups["table"].Value}.{item.Groups["name"].Value}");
|
||||
}
|
||||
|
||||
return (tables, columns);
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, HashSet<string>>> ReadSqliteSchemaAsync(
|
||||
SqliteConnection connection)
|
||||
{
|
||||
var schema = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tables = new List<string>();
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText =
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';";
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync()) tables.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
foreach (var table in tables)
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"PRAGMA table_info(\"{table}\");";
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync()) columns.Add(reader.GetString(1));
|
||||
schema[table] = columns;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// <summary>Walk up from the test binaries to the repo root, then into the
|
||||
/// requested source directory.</summary>
|
||||
private static string FindRepoDirectory(params string[] segments)
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "DorkNet.sln")))
|
||||
dir = dir.Parent;
|
||||
|
||||
Assert.NotNull(dir);
|
||||
var path = Path.Combine(new[] { dir!.FullName }.Concat(segments).ToArray());
|
||||
Assert.True(Directory.Exists(path), $"missing source directory {path}");
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Max players is a PER SUB-ROOM setting, adjustable from two places:
|
||||
///
|
||||
/// in-game PUT rooms/{id}/subrooms/{sub}/maxplayers (owner only — the
|
||||
/// "Max Player Count in This Subroom" slider)
|
||||
/// admin PUT api/admin/v1/rooms/{id}/subrooms/{sub}/maxplayers
|
||||
///
|
||||
/// Both write the same <see cref="RoomSceneEntity.MaxPlayers"/>, so a change
|
||||
/// made in one place has to be visible from the other. That is the property
|
||||
/// worth pinning: two independent write paths onto one column drift silently,
|
||||
/// and the symptom (a slider that snaps back) looks like a client bug.
|
||||
///
|
||||
/// Kept distinct from the room's own <see cref="RoomEntity.MaxCapacity"/>,
|
||||
/// which is what the ROOM advertises and what matchmaking hands to
|
||||
/// RoomInstance.MaxCapacity. Reading the room-level figure off sub-room 0
|
||||
/// would collapse the two settings into one.
|
||||
/// </summary>
|
||||
public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public SubRoomMaxPlayersTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_and_admin_edit_the_same_per_subroom_value()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_700_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Caps{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
MaxCapacity = 12,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Arena", OrderIndex = 1, MaxPlayers = 8 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ── in-game: the owner's slider on sub-room 1 only ──────────────────
|
||||
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
|
||||
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
|
||||
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
|
||||
// most of its siblings use. Testing with a form would pass while the
|
||||
// real client's body went unread.
|
||||
using var ownerClient = Client("rooms", owner);
|
||||
using var slider = await ownerClient.PutAsJsonAsync(
|
||||
$"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
|
||||
var sliderBody = await slider.Content.ReadAsStringAsync();
|
||||
Assert.True(slider.IsSuccessStatusCode,
|
||||
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
|
||||
|
||||
// The client deserialises the reply as FGCPNAACHIK — the room-details
|
||||
// shape — so the slider's own response has to carry the new value.
|
||||
var echoed = JsonDocument.Parse(sliderBody).RootElement
|
||||
.GetProperty("SubRooms").EnumerateArray()
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
|
||||
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
|
||||
|
||||
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
|
||||
using var adminClient = Client("admin", admin);
|
||||
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var subRooms = listed.EnumerateArray()
|
||||
.ToDictionary(e => e.GetProperty("subRoomId").GetInt32(),
|
||||
e => e.GetProperty("maxPlayers").GetInt32());
|
||||
|
||||
Assert.Equal(24, subRooms[1]);
|
||||
Assert.Equal(8, subRooms[0]);
|
||||
|
||||
using var adminSet = await adminClient.PutAsJsonAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 40 });
|
||||
Assert.True(adminSet.IsSuccessStatusCode,
|
||||
$"admin PUT maxplayers -> {(int)adminSet.StatusCode}: {await adminSet.Content.ReadAsStringAsync()}");
|
||||
|
||||
// ── the room's own details carry each sub-room's own figure ─────────
|
||||
var details = await GetJsonAsync(ownerClient, $"/rooms/{roomId}");
|
||||
var wire = details.GetProperty("SubRooms").EnumerateArray()
|
||||
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
|
||||
e => e.GetProperty("MaxPlayers").GetInt32());
|
||||
|
||||
Assert.Equal(40, wire[0]);
|
||||
Assert.Equal(24, wire[1]);
|
||||
|
||||
// The room-level figure is the room's advertised capacity, NOT a copy
|
||||
// of sub-room 0 — otherwise the admin edit above would have moved it.
|
||||
Assert.Equal(12, details.GetProperty("MaxPlayers").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_cap_is_clamped_to_a_joinable_range()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_800_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Clamp{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var adminClient = Client("admin", admin);
|
||||
|
||||
// 0 would make the sub-room unjoinable.
|
||||
using var tooLow = await adminClient.PutAsJsonAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 0 });
|
||||
var low = JsonDocument.Parse(await tooLow.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(1, low.GetProperty("maxPlayers").GetInt32());
|
||||
|
||||
using var tooHigh = await adminClient.PutAsJsonAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 9999 });
|
||||
var high = JsonDocument.Parse(await tooHigh.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(80, high.GetProperty("maxPlayers").GetInt32());
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Event notifications (cheers) now go through the REAL Message channel
|
||||
/// (persisted MessageEntity + MessageReceived push with the client's
|
||||
/// Message DTO shape), instead of the old blank-account misuse of
|
||||
/// SubscriptionUpdateProfile. A cheer must land in the recipient's message
|
||||
/// feed as a PlayerCheer (Type 50) from the cheerer.
|
||||
/// </summary>
|
||||
public sealed class SystemNotificationTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private const int PlayerCheerType = 50;
|
||||
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public SystemNotificationTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Cheering_a_player_creates_a_PlayerCheer_message_notification()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var cheerer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
using var client = ApiClient(cheerer);
|
||||
// Real client field names (RecNet.Runtime KFJKDMGHKHE.EMKBFCLIOGN):
|
||||
// PlayerIdTo + CheerCategory (+ RoomId, Anonymous). The old test used
|
||||
// TargetAccountId/Type, which matched the buggy handler binding but
|
||||
// NOT the game client — so it passed while cheers were broken live.
|
||||
using var resp = await client.PostAsync("/api/PlayerCheer/v1/create",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["PlayerIdTo"] = target.PlayerId.ToString(),
|
||||
["CheerCategory"] = "0",
|
||||
["Anonymous"] = "false",
|
||||
}));
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var msg = await db.Messages.FirstOrDefaultAsync(m =>
|
||||
m.RecipientPlayerId == target.PlayerId && m.Type == PlayerCheerType);
|
||||
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal(cheerer.PlayerId, msg!.SenderPlayerId);
|
||||
// No orphan/blank-account row was written anywhere in the flow.
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// QA test cases linked to GitHub issues.
|
||||
///
|
||||
/// The link lives in <see cref="TestCaseEntity.JiraBugUrl"/> — the field Rec
|
||||
/// Room's own tooling used for the bug filed against a failing case — so no
|
||||
/// column is added and the Postgres "migrations never replay" trap is avoided
|
||||
/// entirely.
|
||||
///
|
||||
/// GitHub itself is faked. These tests are about our behaviour around it:
|
||||
/// filing once rather than per sweep, following issue state in the one
|
||||
/// direction that is safe, and degrading rather than 500ing when no token is
|
||||
/// configured.
|
||||
/// </summary>
|
||||
public sealed class TestCaseGitHubIssueTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public TestCaseGitHubIssueTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
private const int StatusClaimed = 1;
|
||||
private const int StatusFailed = 2;
|
||||
private const int StatusPassed = 3;
|
||||
|
||||
[Fact]
|
||||
public async Task Filing_is_idempotent_and_stores_the_issue_url()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var first = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
var firstBody = await first.Content.ReadAsStringAsync();
|
||||
Assert.True(first.IsSuccessStatusCode, $"first file -> {(int)first.StatusCode}: {firstBody}");
|
||||
Assert.True(JsonDocument.Parse(firstBody).RootElement.GetProperty("created").GetBoolean());
|
||||
|
||||
// The link must be persisted, and in the field the admin UI reads.
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
|
||||
Assert.Contains("/issues/", tc.JiraBugUrl);
|
||||
}
|
||||
|
||||
// Re-running a sweep over a failing pass must not file the bug again.
|
||||
// Measured as a DELTA rather than an absolute count: the fake is shared
|
||||
// with whatever else the server does during the test, and the property
|
||||
// under test is "this call filed nothing", not "nothing was ever filed".
|
||||
var filedBefore = github.Created;
|
||||
using var second = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
var secondBody = await second.Content.ReadAsStringAsync();
|
||||
Assert.True(second.IsSuccessStatusCode, $"second file -> {(int)second.StatusCode}: {secondBody}");
|
||||
Assert.False(JsonDocument.Parse(secondBody).RootElement.GetProperty("created").GetBoolean(),
|
||||
"filing twice created a duplicate issue");
|
||||
Assert.Equal(filedBefore, github.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_closed_issue_marks_the_failing_case_passed()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
Assert.True(filed.IsSuccessStatusCode);
|
||||
|
||||
github.CloseAll();
|
||||
using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(sync.IsSuccessStatusCode, $"sync -> {(int)sync.StatusCode}");
|
||||
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
|
||||
|
||||
// A closed issue means the bug is fixed, so the case that was failing
|
||||
// on it passes.
|
||||
Assert.Equal(StatusPassed, tc.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reopened_issue_marks_the_case_failed_again()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
Assert.True(filed.IsSuccessStatusCode);
|
||||
|
||||
github.CloseAll();
|
||||
using var closeSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(closeSync.IsSuccessStatusCode);
|
||||
|
||||
github.ReopenAll();
|
||||
using var reopenSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(reopenSync.IsSuccessStatusCode);
|
||||
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.Equal(StatusFailed, (await db.TestCases.SingleAsync(c => c.Id == caseId)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_sweep_does_not_stomp_a_tester_mid_claim()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
Assert.True(filed.IsSuccessStatusCode);
|
||||
|
||||
// Someone picks the case up while the issue is still open.
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.TestCases.SingleAsync(c => c.Id == caseId)).Status = StatusClaimed;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(sync.IsSuccessStatusCode);
|
||||
|
||||
await using var check = factory.Services.CreateAsyncScope();
|
||||
var db2 = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var tc = await db2.TestCases.SingleAsync(c => c.Id == caseId);
|
||||
|
||||
// An open issue against a claimed case says nothing new; flipping it to
|
||||
// Failed would yank it out from under whoever is testing it. Only
|
||||
// Failed<->Passed are the reconciler's to move.
|
||||
Assert.Equal(StatusClaimed, tc.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_token_the_endpoint_says_so_instead_of_failing()
|
||||
{
|
||||
// The default fixture has no GitHub configuration, which is what an
|
||||
// ordinary deployment looks like.
|
||||
using var client = await AdminClientAsync(_factory);
|
||||
var caseId = await SeedCaseAsync(_factory, StatusFailed);
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
$"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> WithGitHub(IGitHubIssues github) =>
|
||||
_factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IGitHubIssues>();
|
||||
services.AddSingleton(github);
|
||||
}));
|
||||
|
||||
private async Task<HttpClient> AdminClientAsync(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == session.PlayerId)).IsAdmin = true;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<string> SeedCaseAsync(WebApplicationFactory<Program> factory, int status)
|
||||
{
|
||||
var id = $"tc-{Guid.NewGuid():N}";
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.TestCases.Add(new TestCaseEntity
|
||||
{
|
||||
Id = id,
|
||||
Key = $"QA-{Random.Shared.Next(1000, 9999)}",
|
||||
Title = "Dorm fails to load after cloning a room",
|
||||
Description = "Clone RecCenter, then return to the dorm.",
|
||||
RoomName = "Dorm",
|
||||
Status = status,
|
||||
TagsJson = """["dorm","regression"]""",
|
||||
CommentsJson = """["1811750|2026-07-28T08:00:00Z|Reproduced twice"]""",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>An in-memory GitHub. Records what was filed so the tests can
|
||||
/// assert we did not file twice, and lets them flip issue state.</summary>
|
||||
private sealed class FakeGitHub : IGitHubIssues
|
||||
{
|
||||
private readonly Dictionary<int, GitHubIssue> _issues = [];
|
||||
private int _next = 1;
|
||||
|
||||
public bool IsConfigured => true;
|
||||
|
||||
public string Repository => "DorkSquadRR/DorkNet";
|
||||
|
||||
public int Created { get; private set; }
|
||||
|
||||
public Task<GitHubIssue?> CreateAsync(
|
||||
string title, string body, IEnumerable<string> labels, CancellationToken ct = default)
|
||||
{
|
||||
var number = _next++;
|
||||
var issue = new GitHubIssue(
|
||||
number, $"https://github.com/{Repository}/issues/{number}", "open", title);
|
||||
_issues[number] = issue;
|
||||
Created++;
|
||||
return Task.FromResult<GitHubIssue?>(issue);
|
||||
}
|
||||
|
||||
public Task<GitHubIssue?> GetAsync(int number, CancellationToken ct = default) =>
|
||||
Task.FromResult(_issues.TryGetValue(number, out var issue) ? issue : null);
|
||||
|
||||
public Task<bool> CommentAsync(int number, string body, CancellationToken ct = default) =>
|
||||
Task.FromResult(_issues.ContainsKey(number));
|
||||
|
||||
public void CloseAll() => SetState("closed");
|
||||
|
||||
public void ReopenAll() => SetState("open");
|
||||
|
||||
private void SetState(string state)
|
||||
{
|
||||
foreach (var number in _issues.Keys.ToList())
|
||||
_issues[number] = _issues[number] with { State = state };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Compat;
|
||||
using DorkNet.Server.Controllers.Auth;
|
||||
using DorkNet.Server.Versions.Late2020;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class VersionCheckControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void March_2023_build_is_valid_with_minimal_service_config()
|
||||
{
|
||||
var controller = CreateController();
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.Check("20230321", "0"));
|
||||
|
||||
Assert.Equal(0, ReadVersionStatus(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_build_still_requires_update()
|
||||
{
|
||||
var controller = CreateController();
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.Check("unknown", "0"));
|
||||
|
||||
Assert.Equal(2, ReadVersionStatus(result.Value));
|
||||
}
|
||||
|
||||
private static VersionCheckController CreateController()
|
||||
{
|
||||
var config = new ConfigurationBuilder().Build();
|
||||
var registry = new VersionRegistry(
|
||||
[new Late2020VersionPlugin()],
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"march_2023_03_21",
|
||||
},
|
||||
"march_2023_03_21");
|
||||
|
||||
return new VersionCheckController(
|
||||
config,
|
||||
registry,
|
||||
NullLogger<VersionCheckController>.Instance);
|
||||
}
|
||||
|
||||
private static int ReadVersionStatus(object? value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return document.RootElement.GetProperty("VersionStatus").GetInt32();
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 client's watch Shop tab pipeline: rows come from
|
||||
/// <c>/api/storefronts/v3/giftdropstore/{id}</c> (each row MUST carry a
|
||||
/// singular <c>GiftDrop</c> object — the 2023 POGBAGAHGIA formatter
|
||||
/// ignores the 2020 <c>GiftDrops</c> array, and a null inner desc makes
|
||||
/// StoreItemListModel's filter predicate NRE so the Shop shows nothing),
|
||||
/// and the featured strip ids come from <c>/v1/toptoday</c> as a BARE
|
||||
/// int list that must resolve against those rows.
|
||||
/// </summary>
|
||||
public sealed class WatchShop2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public WatchShop2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Giftdropstore_rows_carry_singular_giftdrop_and_toptoday_ids_resolve()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var rows = storefront.GetProperty("StoreItems");
|
||||
Assert.True(rows.GetArrayLength() > 0, "watch storefront seeded no rows");
|
||||
|
||||
var rowIds = new HashSet<int>();
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
rowIds.Add(row.GetProperty("PurchasableItemId").GetInt32());
|
||||
Assert.Equal(0, row.GetProperty("Type").GetInt32());
|
||||
Assert.True(row.GetProperty("Prices").GetArrayLength() > 0);
|
||||
|
||||
var giftDrop = row.GetProperty("GiftDrop");
|
||||
Assert.Equal(JsonValueKind.Object, giftDrop.ValueKind);
|
||||
Assert.Equal(row.GetProperty("PurchasableItemId").GetInt32(),
|
||||
giftDrop.GetProperty("GiftDropId").GetInt32());
|
||||
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("FriendlyName").ValueKind);
|
||||
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("TagList").ValueKind);
|
||||
var rarity = giftDrop.GetProperty("Rarity").GetInt32();
|
||||
Assert.Contains(rarity, new[] { 0, 10, 20, 30, 50 });
|
||||
|
||||
// Int32 on the 2023 wire — a descriptor STRING here makes the
|
||||
// strict Utf8Json reader throw "Malformed Response" for the
|
||||
// whole storefront payload (expected:'Number Token').
|
||||
Assert.Equal(JsonValueKind.Number, giftDrop.GetProperty("AvatarItemId").ValueKind);
|
||||
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("AvatarItemDesc").ValueKind);
|
||||
}
|
||||
|
||||
// Featured strip: bare int ids, every one resolvable in the rows.
|
||||
var topToday = await GetJsonAsync(client, "/api/storefronts/v1/toptoday");
|
||||
Assert.Equal(JsonValueKind.Array, topToday.ValueKind);
|
||||
Assert.True(topToday.GetArrayLength() > 0, "toptoday returned no featured ids");
|
||||
foreach (var id in topToday.EnumerateArray())
|
||||
{
|
||||
Assert.Equal(JsonValueKind.Number, id.ValueKind);
|
||||
Assert.Contains(id.GetInt32(), rowIds);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Storefront_includes_food_consumables_with_literal_desc()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var rows = storefront.GetProperty("StoreItems");
|
||||
|
||||
// Food / KO / camera consumables surface with the client's literal
|
||||
// ConsumableItemDesc and no avatar item — that's what routes them to
|
||||
// the Shop's Consumables tab and binds the baked prefab. (Hair dyes
|
||||
// are a hybrid: they also carry an AvatarItemDesc colour guid, so
|
||||
// they're excluded from the no-avatar-item check.)
|
||||
var byDesc = new Dictionary<string, JsonElement>();
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
var gd = row.GetProperty("GiftDrop");
|
||||
var desc = gd.GetProperty("ConsumableItemDesc").GetString() ?? "";
|
||||
if (desc.Length > 0) byDesc[desc] = gd;
|
||||
}
|
||||
|
||||
foreach (var key in new[] { "[FoodConsumable_RootBeer]", "[KOConsumable_Cola]" })
|
||||
{
|
||||
Assert.True(byDesc.TryGetValue(key, out var gd), $"missing consumable {key}");
|
||||
Assert.Equal("", gd.GetProperty("AvatarItemDesc").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, gd.GetProperty("AvatarItemType").ValueKind);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -1,2815 +0,0 @@
|
||||
{
|
||||
"AALDCAANEMM": [
|
||||
"CurrencyPurchaseOfferId",
|
||||
"CurrencyId",
|
||||
"Order",
|
||||
"Name",
|
||||
"CurrencyAmount",
|
||||
"Price",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"ABJLKAFOPMP": [
|
||||
"GiftDropIds",
|
||||
"Message",
|
||||
"SubscriptionPurchase"
|
||||
],
|
||||
"ACKNGEEHHAE": [
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"Tags",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions"
|
||||
],
|
||||
"ADAFNGNADFH": [
|
||||
"InviteeAccountId",
|
||||
"CreatedAt",
|
||||
"VerifiedAt"
|
||||
],
|
||||
"ADDGMGGOLHM": [
|
||||
"BlobName",
|
||||
"Title",
|
||||
"Description",
|
||||
"ThumbnailBlobName",
|
||||
"SourceUrl"
|
||||
],
|
||||
"ADMGGPDGKCE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"AECJMONHBHM": [
|
||||
"ClubId",
|
||||
"LastReadAnnouncementId",
|
||||
"Announcements"
|
||||
],
|
||||
"AEGADMDIKNE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"AEGCKMKICFG": [
|
||||
"AccountId",
|
||||
"UserName",
|
||||
"DisplayName",
|
||||
"DisplayEmoji",
|
||||
"ProfileImage",
|
||||
"BannerImage",
|
||||
"TreatAsJunior",
|
||||
"HasBirthday",
|
||||
"PersonalPronouns",
|
||||
"IdentityFlags",
|
||||
"CreatedAt",
|
||||
"IsJunior"
|
||||
],
|
||||
"AEHFGHPLNDO": [
|
||||
"AccountId",
|
||||
"LastVisitedAt"
|
||||
],
|
||||
"AFHCKGCJNFM": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"AFJDLEFKNFP": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"AHJKILCAGML": [
|
||||
"ChallengeId",
|
||||
"Name",
|
||||
"Config",
|
||||
"Description",
|
||||
"Tooltip",
|
||||
"Complete"
|
||||
],
|
||||
"AIEJFLGKIGN": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"AJEDJLBJLFA": [
|
||||
"AccountId",
|
||||
"ClubId",
|
||||
"MembershipType",
|
||||
"CreatedAt"
|
||||
],
|
||||
"AKDFPANIAFL": [
|
||||
"AnnouncementId",
|
||||
"AnnouncementType",
|
||||
"Title",
|
||||
"Body",
|
||||
"ImageName",
|
||||
"LinkType",
|
||||
"LinkName",
|
||||
"LinkButtonLabel",
|
||||
"LinkUri",
|
||||
"Platform",
|
||||
"CreatedAt"
|
||||
],
|
||||
"AKKPACEJJFK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"ALGKCAKCFAJ": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"ALJPHDEAHBK": [
|
||||
"Result",
|
||||
"Tags"
|
||||
],
|
||||
"AMPFGJPKHOD": [
|
||||
"Device_code",
|
||||
"User_code",
|
||||
"Verification_uri",
|
||||
"Verification_uri_complete",
|
||||
"Expires_in",
|
||||
"Interval"
|
||||
],
|
||||
"ANHGLIFGACE": [
|
||||
"RoomKeyId",
|
||||
"ReplicationId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"PurchaseCurrencyId",
|
||||
"CreatedAt",
|
||||
"ImageName"
|
||||
],
|
||||
"ANNOJCNMHIG": [
|
||||
"CustomAvatarItemId",
|
||||
"CreatorAccountId",
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"Accessibility",
|
||||
"IsFeatured",
|
||||
"BaseAvatarItemId",
|
||||
"BaseAvatarItemColor",
|
||||
"DesignFilename",
|
||||
"ThumbnailImageFilename",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"ANPJKOOEEBB": [
|
||||
"Identifier",
|
||||
"Genuine",
|
||||
"GenuineCheckAvailable"
|
||||
],
|
||||
"AOAACMFGAGK": [
|
||||
"ChallengeMapId",
|
||||
"ChallengeThemeString",
|
||||
"Challenges",
|
||||
"Gift",
|
||||
"FallbackGiftName",
|
||||
"StartAt",
|
||||
"EndAt",
|
||||
"ServerTime",
|
||||
"CompletedRequired"
|
||||
],
|
||||
"APOJHADGCNA": [
|
||||
"Ids",
|
||||
"CreatedAts",
|
||||
"ConsumableItemDesc",
|
||||
"Count",
|
||||
"InitialCount",
|
||||
"IsActive",
|
||||
"ActiveDurationMinutes",
|
||||
"IsTransferable"
|
||||
],
|
||||
"BAEBDMPPKKE": [
|
||||
"Take",
|
||||
"ContinuationToken",
|
||||
"InjectedFeedItems",
|
||||
"FeedInstanceId"
|
||||
],
|
||||
"BBACJMOPFPE": [
|
||||
"ProgressionEventRewardId",
|
||||
"GiftDropId",
|
||||
"ImageName",
|
||||
"Xp",
|
||||
"RewardIndex",
|
||||
"IsBonus"
|
||||
],
|
||||
"BBEFAELEANL": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"BBFGGPEMLLE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"BBMJLKMGFGA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"BCEJDGGBBHC": [
|
||||
"Price",
|
||||
"CurrencyId"
|
||||
],
|
||||
"BCMOCNHAJCL": [
|
||||
"Status",
|
||||
"Consumable"
|
||||
],
|
||||
"BGFBJCIOIFP": [
|
||||
"SkuId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"Price",
|
||||
"OculusSkuId",
|
||||
"AppleProductId",
|
||||
"PsnProductLabel",
|
||||
"XboxProductId",
|
||||
"XboxStoreId",
|
||||
"GooglePlaySkuId",
|
||||
"PicoSkuId",
|
||||
"IsSingleUse",
|
||||
"ShouldAppearInTokenStore",
|
||||
"Data"
|
||||
],
|
||||
"BIMHOENEGII": [
|
||||
"Group",
|
||||
"IsCompleted",
|
||||
"ClearedAt"
|
||||
],
|
||||
"BJHOKILBIML": [
|
||||
"FeedItemType",
|
||||
"FeedItemId"
|
||||
],
|
||||
"BKCOBJILFEI": [
|
||||
"KeepsakeFeatureEnabled",
|
||||
"KeepsakeRoomLimit",
|
||||
"SocialXpBoostEnabled"
|
||||
],
|
||||
"BKFOGBGPLFP": [
|
||||
"SavedImageId",
|
||||
"ImageName",
|
||||
"Username",
|
||||
"PlayerId",
|
||||
"RoomName",
|
||||
"RoomId"
|
||||
],
|
||||
"BLADEIPEPOH": [
|
||||
"SavedImageId",
|
||||
"ImageName",
|
||||
"PlayerId",
|
||||
"RoomId",
|
||||
"PlayerEventId",
|
||||
"ClubId",
|
||||
"Description",
|
||||
"Accessibility",
|
||||
"AccessibilityLocked",
|
||||
"SavedImageType",
|
||||
"CreatedAt",
|
||||
"CheerCount",
|
||||
"CommentCount"
|
||||
],
|
||||
"BLIBJIHOENF": [
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"MainImageName",
|
||||
"State",
|
||||
"CreatorAccountId",
|
||||
"Category",
|
||||
"Visibility",
|
||||
"Joinability",
|
||||
"AllowJuniors",
|
||||
"MinLevel",
|
||||
"MemberCount",
|
||||
"IsRRO",
|
||||
"ClubChatEnabled",
|
||||
"ClubhouseRoomId",
|
||||
"ClubType"
|
||||
],
|
||||
"BLPIIBBPOJM": [
|
||||
"Action",
|
||||
"Reason",
|
||||
"Error"
|
||||
],
|
||||
"BLPNLMALDAC": [
|
||||
"AccountId",
|
||||
"Bio"
|
||||
],
|
||||
"BPAPIKPFDAH": [
|
||||
"Difficulty",
|
||||
"EN_US"
|
||||
],
|
||||
"BPHCOIBNCDP": [
|
||||
"ClubId",
|
||||
"Type",
|
||||
"EditDetails",
|
||||
"ApproveMember",
|
||||
"CreateEvent",
|
||||
"PostAnnouncement",
|
||||
"EditPermissionSettings",
|
||||
"BanUnban"
|
||||
],
|
||||
"CAAJPHFCIPK": [
|
||||
"ReportCategory",
|
||||
"Reason"
|
||||
],
|
||||
"CAEHDIMCGLD": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"CAKCKAMAAPP": [
|
||||
"ClubId",
|
||||
"LastAnnouncementId",
|
||||
"LastReadAnnouncementId",
|
||||
"Announcements"
|
||||
],
|
||||
"CBKDKACJDOF": [
|
||||
"ChatMessageId",
|
||||
"ChatThreadId",
|
||||
"SenderPlayerId",
|
||||
"TimeSent",
|
||||
"Contents",
|
||||
"ModerationState"
|
||||
],
|
||||
"CBNEDELBFAJ": [
|
||||
"Clubs",
|
||||
"TotalClubs",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"CCAGOJFGLLO": [
|
||||
"FeedItemType",
|
||||
"FeedItemId",
|
||||
"FeedAlgorithmVersion"
|
||||
],
|
||||
"CCKEEAMMHPJ": [
|
||||
"UnityAssetId",
|
||||
"CreatedByAccountId",
|
||||
"BakedUnityAssets"
|
||||
],
|
||||
"CDEDNEPPPND": [
|
||||
"LevelProgressionMaps",
|
||||
"DailyObjectives",
|
||||
"ServerMaintenance",
|
||||
"AutoMicMutingConfig",
|
||||
"StorefrontConfig",
|
||||
"RoomKeyConfig",
|
||||
"RoomCurrencyConfig",
|
||||
"ShareBaseUrl"
|
||||
],
|
||||
"CEAMINNJFGO": [
|
||||
"Left",
|
||||
"Right",
|
||||
"Spine",
|
||||
"Pelvis",
|
||||
"Root",
|
||||
"Torso",
|
||||
"Body",
|
||||
"Hips",
|
||||
"Neck",
|
||||
"Chest",
|
||||
"Head",
|
||||
"Arm",
|
||||
"Hand",
|
||||
"Wrist",
|
||||
"Elbow",
|
||||
"Palm",
|
||||
"Leg",
|
||||
"Thigh",
|
||||
"Calf",
|
||||
"Femur",
|
||||
"Knee",
|
||||
"Foot",
|
||||
"Ankle",
|
||||
"Hip",
|
||||
"Tail",
|
||||
"Eye",
|
||||
"Dummy",
|
||||
"Collar",
|
||||
"Clavicle",
|
||||
"Finger",
|
||||
"Index",
|
||||
"Mid",
|
||||
"Pinky",
|
||||
"Thumb",
|
||||
"Adjust",
|
||||
"Twist",
|
||||
"Toe",
|
||||
"Lid",
|
||||
"Brow",
|
||||
"Lash"
|
||||
],
|
||||
"CENEMCMGDKG": [
|
||||
"Tags",
|
||||
"PlayerEventId",
|
||||
"CreatorPlayerId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"AttendeeCount",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions",
|
||||
"BroadcastingRoomInstanceId"
|
||||
],
|
||||
"CGACOKHBJCB": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"CGCKIMIEHNM": [
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"CHEMHPGCJBM": [
|
||||
"PlayerId",
|
||||
"Score",
|
||||
"Rank"
|
||||
],
|
||||
"CHKOLCGMCKJ": [
|
||||
"ReportBudget",
|
||||
"FilterType",
|
||||
"SampleRate",
|
||||
"LogLineCount",
|
||||
"CaptureNativeCrashes",
|
||||
"ANRThresholdMs",
|
||||
"MessageCount",
|
||||
"MessageRegex",
|
||||
"VersionRegex"
|
||||
],
|
||||
"CICAKEDCJLA": [
|
||||
"Email",
|
||||
"Phone",
|
||||
"Birthday",
|
||||
"JuniorState",
|
||||
"ParentAccountId",
|
||||
"AvailableUsernameChanges",
|
||||
"AccountId",
|
||||
"UserName",
|
||||
"DisplayName",
|
||||
"DisplayEmoji",
|
||||
"ProfileImage",
|
||||
"BannerImage",
|
||||
"TreatAsJunior",
|
||||
"HasBirthday",
|
||||
"PersonalPronouns",
|
||||
"IdentityFlags",
|
||||
"CreatedAt",
|
||||
"IsJunior"
|
||||
],
|
||||
"CICEHLNDLDE": [
|
||||
"RoomConsumableId",
|
||||
"AccountId",
|
||||
"Count",
|
||||
"ConcurrencyCode",
|
||||
"ModifiedAt",
|
||||
"Consumable"
|
||||
],
|
||||
"CIKAJCJMOFJ": [
|
||||
"MembershipRequestId",
|
||||
"AccountId",
|
||||
"InviterAccountId",
|
||||
"ClubId",
|
||||
"MembershipType",
|
||||
"MembershipStatus",
|
||||
"CreatedAt"
|
||||
],
|
||||
"CJPKAAJBLAJ": [
|
||||
"Id",
|
||||
"Name",
|
||||
"Description",
|
||||
"StartDate",
|
||||
"EndDate",
|
||||
"WasManuallyClosed",
|
||||
"TestCases",
|
||||
"Tags",
|
||||
"NumTestCases",
|
||||
"NumPassedTestCases",
|
||||
"NumFailedTestCases"
|
||||
],
|
||||
"CLHBPPNOLHP": [
|
||||
"Version",
|
||||
"ButtonNumber",
|
||||
"Override",
|
||||
"CustomRoomName",
|
||||
"CustomTitle",
|
||||
"CustomDescription",
|
||||
"DefaultRoomName",
|
||||
"DefaultTitle",
|
||||
"DefaultDescription"
|
||||
],
|
||||
"CLIANCNHNIE": [
|
||||
"AvatarItemType",
|
||||
"AvatarItemDesc",
|
||||
"FriendlyName",
|
||||
"Tooltip",
|
||||
"Rarity",
|
||||
"TagList",
|
||||
"AvatarItemId",
|
||||
"IsBaseAvatarItem"
|
||||
],
|
||||
"CMBHBADOOAF": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"CMHEJKENPBH": [
|
||||
"TriggerType",
|
||||
"TriggerId",
|
||||
"TriggeredAt"
|
||||
],
|
||||
"CMLNDGCIPIH": [
|
||||
"Index",
|
||||
"Group",
|
||||
"Progress",
|
||||
"VisualProgress",
|
||||
"IsCompleted",
|
||||
"HasClaimedReward"
|
||||
],
|
||||
"CNFFPECMOHE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"CNNAFLNKDCL": [
|
||||
"AccountId",
|
||||
"KeepsakeInstanceId",
|
||||
"CollectedAt"
|
||||
],
|
||||
"CNNEAFEIMPP": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"COIELAHKALG": [
|
||||
"Enabled",
|
||||
"Key",
|
||||
"Region"
|
||||
],
|
||||
"DCECPNPOEFG": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DDDLCGDEPNH": [
|
||||
"PlayerEventResponseId",
|
||||
"PlayerEventId",
|
||||
"PlayerId",
|
||||
"CreatedAt",
|
||||
"Type"
|
||||
],
|
||||
"DDFBPFOBIAO": [
|
||||
"ImageName",
|
||||
"Accessibility"
|
||||
],
|
||||
"DDFNNBDLBKF": [
|
||||
"AccountId",
|
||||
"ClubId",
|
||||
"SubscriberCount"
|
||||
],
|
||||
"DEIDIGKKLHE": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"DFKDBJMNKCO": [
|
||||
"Event",
|
||||
"NewSubscription"
|
||||
],
|
||||
"DHAPGKPJDKH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DHMKNFOLCGM": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DICIOFKDIEE": [
|
||||
"Season",
|
||||
"Name",
|
||||
"StartAt",
|
||||
"EndAt",
|
||||
"CurrencyType",
|
||||
"EliteUpgrade",
|
||||
"Tiers",
|
||||
"PersonalDetails",
|
||||
"StorefrontType",
|
||||
"NextUpdate",
|
||||
"NewUntil"
|
||||
],
|
||||
"DIEEOOKELHG": [
|
||||
"AccountId",
|
||||
"IsRecentHistoryVisible"
|
||||
],
|
||||
"DILNAKMDJOF": [
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"KeepsakeCategory"
|
||||
],
|
||||
"DJCLNEEBAJP": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DJFEOAGKKAK": [
|
||||
"FreshnessSeconds",
|
||||
"Url"
|
||||
],
|
||||
"DJFIAOIFLDI": [
|
||||
"Memberships",
|
||||
"TotalMemberships",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"DJHIABKJLBD": [
|
||||
"RoomInstanceId",
|
||||
"PhotonAccessToken",
|
||||
"Permissions"
|
||||
],
|
||||
"DKHBLLDMDHB": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DKMBKPLABAG": [
|
||||
"Type",
|
||||
"Score"
|
||||
],
|
||||
"DKNKIEOODHK": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DLJCLHBGADL": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DNBAAJILMPE": [
|
||||
"RoomId",
|
||||
"ClubId",
|
||||
"PlayerCount"
|
||||
],
|
||||
"DOINHLNFOCP": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DOJEJNECAAI": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DOMOBFDIAJA": [
|
||||
"AccountId",
|
||||
"CheeredAt"
|
||||
],
|
||||
"DPHFOPBAAPN": [
|
||||
"PlayerId",
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"FilterType",
|
||||
"SortAscending"
|
||||
],
|
||||
"DPIECEMDKPL": [
|
||||
"CreatorPlayerId",
|
||||
"Data",
|
||||
"IsValid"
|
||||
],
|
||||
"DPKHIGPMCIP": [
|
||||
"InviteId",
|
||||
"Name"
|
||||
],
|
||||
"DPNLANKOHON": [
|
||||
"AccountId",
|
||||
"CurrencyId",
|
||||
"Balance",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"DatatypeImplementation": [
|
||||
"ENTITIES",
|
||||
"ENTITY",
|
||||
"ID",
|
||||
"IDREF",
|
||||
"IDREFS",
|
||||
"NMTOKEN",
|
||||
"NMTOKENS",
|
||||
"NOTATION"
|
||||
],
|
||||
"EACKMEGHIGM": [
|
||||
"AccountId",
|
||||
"ClubId",
|
||||
"CreatedAt"
|
||||
],
|
||||
"ECEBBLBCFKO": [
|
||||
"AccountId",
|
||||
"BannedByAccountId",
|
||||
"BanStartTime"
|
||||
],
|
||||
"ECHJAKNMEEC": [
|
||||
"KeepsakeRoomListId",
|
||||
"ProgressionEventId",
|
||||
"UnlockItemAvatarItemId",
|
||||
"UnlockItemGiftDropId",
|
||||
"UnlockItemLockDurationTicks",
|
||||
"RoomUnlockStartOffsetTicks",
|
||||
"RoomUnlockIntervalTicks",
|
||||
"RoomUnlockBatchSize",
|
||||
"RoomType",
|
||||
"KeepsakeRooms"
|
||||
],
|
||||
"ECIINMENOGP": [
|
||||
"BalanceAddType",
|
||||
"Delta",
|
||||
"Balance",
|
||||
"Platform",
|
||||
"CurrencyType"
|
||||
],
|
||||
"EDMKBBPGJAB": [
|
||||
"Balance",
|
||||
"RoomKeyResponse"
|
||||
],
|
||||
"EEAAGIJHLOJ": [
|
||||
"PlayerEvent",
|
||||
"Result",
|
||||
"TagModifyResult"
|
||||
],
|
||||
"EHFKGCIDOFL": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"EHKEOCKBCDH": [
|
||||
"Items",
|
||||
"FeedInstanceId",
|
||||
"FeedContext",
|
||||
"FeedAlgorithmVersion"
|
||||
],
|
||||
"EIFIAMHKFAG": [
|
||||
"PurchaseReminderId",
|
||||
"SkuId",
|
||||
"TokenPrice",
|
||||
"Title",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"EndDate",
|
||||
"BonusGiftDrops"
|
||||
],
|
||||
"EJEJHJPCBOE": [
|
||||
"KeepsakeRoomId",
|
||||
"RoomId",
|
||||
"KeepsakeRoomListId",
|
||||
"Type",
|
||||
"Order"
|
||||
],
|
||||
"EJIHDJDKKEC": [
|
||||
"AccountId",
|
||||
"RoomKeyId"
|
||||
],
|
||||
"EJNFJPNOFFO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"EKEFFNBIOHJ": [
|
||||
"ChatThreadId",
|
||||
"LastReadMessageId",
|
||||
"Messages",
|
||||
"LatestMessage",
|
||||
"PlayerIds",
|
||||
"ChatThreadName",
|
||||
"SnoozedUntil",
|
||||
"IsFavorited",
|
||||
"ClubId"
|
||||
],
|
||||
"EKHJKDNHOPB": [
|
||||
"OutfitSelections",
|
||||
"OutfitSelectionsV2",
|
||||
"FaceFeatures",
|
||||
"SkinColor",
|
||||
"HairColor",
|
||||
"CustomAvatarItems"
|
||||
],
|
||||
"EKMLNNNABAD": [
|
||||
"UnityAssetId",
|
||||
"RoomData",
|
||||
"SubRoomData",
|
||||
"InventionUsage",
|
||||
"PersistenceVersion",
|
||||
"Description",
|
||||
"AutoPublish"
|
||||
],
|
||||
"EMBCEDNHFLB": [
|
||||
"GiftDropId",
|
||||
"FriendlyName",
|
||||
"Tooltip",
|
||||
"TagList",
|
||||
"ConsumableItemDesc",
|
||||
"AvatarItemDesc",
|
||||
"AvatarItemType",
|
||||
"EquipmentPrefabName",
|
||||
"EquipmentModificationGuid",
|
||||
"IsQuery",
|
||||
"Unique",
|
||||
"SubscribersOnly",
|
||||
"Rarity",
|
||||
"AvatarItemId",
|
||||
"CurrencyType",
|
||||
"Currency",
|
||||
"Context",
|
||||
"ItemSetId",
|
||||
"ItemSetFriendlyName"
|
||||
],
|
||||
"EMJAJEHCIFN": [
|
||||
"MarketingEmails",
|
||||
"InviteEmails",
|
||||
"GameClientTextInvites",
|
||||
"FriendRequestEmails",
|
||||
"CreatorPublishedNewRoomEmails",
|
||||
"MyRoomSummaryEmails",
|
||||
"NewPhotoTagEmails",
|
||||
"PlayerEventInviteEmails",
|
||||
"PlayerEventReminderEmails"
|
||||
],
|
||||
"ENJDEONHNMO": [
|
||||
"Created",
|
||||
"Responses"
|
||||
],
|
||||
"EOFEKNIHNNC": [
|
||||
"PlayerEventId",
|
||||
"BroadcastRoomInstanceId"
|
||||
],
|
||||
"EOILOIMIJLJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"EOIMIBBJBCB": [
|
||||
"ImageName",
|
||||
"Title",
|
||||
"Subtitle"
|
||||
],
|
||||
"EPPPACFECMH": [
|
||||
"Type",
|
||||
"Reference"
|
||||
],
|
||||
"EncodingTable": [
|
||||
"Big5",
|
||||
"GB2312",
|
||||
"IBM850",
|
||||
"IBM852",
|
||||
"IBM857",
|
||||
"IBM861",
|
||||
"IBM869"
|
||||
],
|
||||
"FAMGNALGHPN": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FBNOJCDDBJK": [
|
||||
"XPAwardStrings",
|
||||
"TotalXPAwarded",
|
||||
"NewProgress"
|
||||
],
|
||||
"FCIBLPCOODP": [
|
||||
"RoomConsumableId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"Price",
|
||||
"PurchaseCurrencyId",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"FCJNIBGKHDM": [
|
||||
"ClubId",
|
||||
"MembershipType"
|
||||
],
|
||||
"FDPFICCCJNM": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FEJOMKJBOOE": [
|
||||
"RoomId",
|
||||
"EarningsDistributionMapping",
|
||||
"EarningsDistributionMethod"
|
||||
],
|
||||
"FIFFMMLKKCJ": [
|
||||
"PlayerId",
|
||||
"Level",
|
||||
"XP"
|
||||
],
|
||||
"FJJIJOGNMCP": [
|
||||
"ReferralRewardTierId",
|
||||
"RequiredReferralCount",
|
||||
"RewardSelectionCount"
|
||||
],
|
||||
"FJNBDGFCLOF": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"FKANCKLCCDI": [
|
||||
"Id",
|
||||
"FromPlayerId",
|
||||
"ConsumableItemDesc",
|
||||
"AvatarItemType",
|
||||
"AvatarItemDesc",
|
||||
"EquipmentPrefabName",
|
||||
"EquipmentModificationGuid",
|
||||
"CurrencyType",
|
||||
"Currency",
|
||||
"Xp",
|
||||
"GiftContext",
|
||||
"GiftRarity",
|
||||
"Message",
|
||||
"Platform",
|
||||
"PlatformsToSpawnOn",
|
||||
"BalanceType"
|
||||
],
|
||||
"FKPPDBLFKHJ": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FLEDNKHKIND": [
|
||||
"InfluencerIds",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"FLELPHJDLNG": [
|
||||
"Filename",
|
||||
"Hash",
|
||||
"OwnershipProof"
|
||||
],
|
||||
"FLMCOHEGJEI": [
|
||||
"RequiresEliteUpgrade",
|
||||
"GiftDrop",
|
||||
"SeasonXpBoost"
|
||||
],
|
||||
"FMIDLABJCLM": [
|
||||
"Strings",
|
||||
"StringPackageVersion"
|
||||
],
|
||||
"FMKALNIKMKF": [
|
||||
"AccountId",
|
||||
"MembershipType"
|
||||
],
|
||||
"FMNDPHMOIIH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FNDBGOGAIFL": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"FNIDAIDJBBO": [
|
||||
"PlayerID",
|
||||
"RelationshipType",
|
||||
"Muted",
|
||||
"Ignored",
|
||||
"Favorited"
|
||||
],
|
||||
"FNNAJDFFICN": [
|
||||
"ReportId",
|
||||
"Client"
|
||||
],
|
||||
"FOIMFMEEGDI": [
|
||||
"SupportsJoinInProgress",
|
||||
"UseLevelBasedMatchmaking",
|
||||
"UseAgeBasedMatchmaking",
|
||||
"UseRecRoyaleMatchmaking",
|
||||
"SubRoomId",
|
||||
"RoomId",
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"UnityAssetId",
|
||||
"UnityAsset",
|
||||
"UnityAssetHash",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"DataSavedAt",
|
||||
"IsSandbox",
|
||||
"MaxPlayers",
|
||||
"Accessibility"
|
||||
],
|
||||
"FOKKDBJEFOO": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"FOOOKJCPBKC": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FPGBBCLENDA": [
|
||||
"StorefrontType",
|
||||
"NextUpdate",
|
||||
"NewUntil"
|
||||
],
|
||||
"FPLGJGDJIJG": [
|
||||
"StorefrontType",
|
||||
"PurchasableItemId",
|
||||
"CurrencyType",
|
||||
"RequestedPrice",
|
||||
"CouponConsumablePlayerMappingId",
|
||||
"Gift"
|
||||
],
|
||||
"FPNMHECOPPF": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"FPNOOOFJBJF": [
|
||||
"RankStart",
|
||||
"RankEnd",
|
||||
"PlayerId",
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"FilterType",
|
||||
"SortAscending"
|
||||
],
|
||||
"FastDualShock4GamepadHID": [
|
||||
"Dpad",
|
||||
"Square",
|
||||
"Cross",
|
||||
"Circle",
|
||||
"Triangle"
|
||||
],
|
||||
"FastKeyboard": [
|
||||
"AnyKey",
|
||||
"Escape",
|
||||
"Space",
|
||||
"Enter",
|
||||
"Tab",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"Shift",
|
||||
"Alt",
|
||||
"Backspace",
|
||||
"Home",
|
||||
"End",
|
||||
"Insert",
|
||||
"Delete",
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12"
|
||||
],
|
||||
"FastMouse": [
|
||||
"Pressure",
|
||||
"Radius",
|
||||
"Position",
|
||||
"Delta",
|
||||
"Scroll",
|
||||
"Press"
|
||||
],
|
||||
"FastTouchscreen": [
|
||||
"Pressure",
|
||||
"Radius",
|
||||
"Position",
|
||||
"Delta",
|
||||
"Press"
|
||||
],
|
||||
"GACOLGJKHBI": [
|
||||
"VersionStatus",
|
||||
"UpdateNotificationStage"
|
||||
],
|
||||
"GAGIJHJGFJD": [
|
||||
"AccountId",
|
||||
"CurrencyId",
|
||||
"Balance",
|
||||
"AmountAwarded",
|
||||
"AwardedAt"
|
||||
],
|
||||
"GAODOHODBDE": [
|
||||
"FailedInvites",
|
||||
"Result"
|
||||
],
|
||||
"GBPDOLJBABB": [
|
||||
"Success",
|
||||
"Message"
|
||||
],
|
||||
"GBPHFPMCGHH": [
|
||||
"Subscription",
|
||||
"PlatformAccountSubscribedPlayerId"
|
||||
],
|
||||
"GCKGIDLDBNH": [
|
||||
"Error",
|
||||
"Error_description",
|
||||
"Access_token",
|
||||
"Refresh_token",
|
||||
"Key"
|
||||
],
|
||||
"GDGCOAMMCIH": [
|
||||
"MicSpamVolumeThreshold",
|
||||
"MicVolumeSampleInterval",
|
||||
"MicVolumeSampleRollingWindowLength",
|
||||
"MicSpamSamplePercentageForWarning",
|
||||
"MicSpamSamplePercentageForWarningToEnd",
|
||||
"MicSpamSamplePercentageForForceMute",
|
||||
"MicSpamSamplePercentageForForceMuteToEnd",
|
||||
"MicSpamWarningStateVolumeMultiplier"
|
||||
],
|
||||
"GEEKOKDMAHG": [
|
||||
"AccountId",
|
||||
"ApiKey"
|
||||
],
|
||||
"GEGADPCDGBO": [
|
||||
"Tag",
|
||||
"Type"
|
||||
],
|
||||
"GEMBOCLPFGE": [
|
||||
"AccountId",
|
||||
"FavoritedAt"
|
||||
],
|
||||
"GFCIGEIEPGH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"GFGBAAADKEL": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"GFGKDCENJMB": [
|
||||
"AccountId",
|
||||
"Xp",
|
||||
"GameMinutesToday",
|
||||
"RewardsCollected",
|
||||
"BonusRewardsCollected",
|
||||
"XpBoostLastPurchasedAt"
|
||||
],
|
||||
"GFHNJMMBHFD": [
|
||||
"PlayerEventId",
|
||||
"Type"
|
||||
],
|
||||
"GFIHPOOFHLC": [
|
||||
"AnnouncementId",
|
||||
"CreatorAccountId",
|
||||
"ClubId",
|
||||
"Title",
|
||||
"Body",
|
||||
"ImageName",
|
||||
"CreatedAt",
|
||||
"Meta"
|
||||
],
|
||||
"GFKKOADFJKC": [
|
||||
"CurrencyId",
|
||||
"RecipientId",
|
||||
"Amount",
|
||||
"TransactionId"
|
||||
],
|
||||
"GFMOBMCIOJI": [
|
||||
"AccountId",
|
||||
"IsCheerful",
|
||||
"SelectedCheer",
|
||||
"CheerCredit",
|
||||
"CheerGeneral",
|
||||
"CheerHelpful",
|
||||
"CheerCreative",
|
||||
"CheerGreatHost",
|
||||
"CheerSportsman"
|
||||
],
|
||||
"GGFHKCDELJH": [
|
||||
"Id",
|
||||
"Link"
|
||||
],
|
||||
"GHBIGGJKPFI": [
|
||||
"Events",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"GIFNHCGDNEM": [
|
||||
"Platform",
|
||||
"PlatformId",
|
||||
"AccountId",
|
||||
"LastLoginTime",
|
||||
"RequirePassword",
|
||||
"RefreshToken"
|
||||
],
|
||||
"GIJCKEDKGLH": [
|
||||
"ReportCategory",
|
||||
"PlayerEventId",
|
||||
"Details"
|
||||
],
|
||||
"GJGLBNABMNL": [
|
||||
"CreatorAccountId",
|
||||
"ClubId",
|
||||
"MembershipType"
|
||||
],
|
||||
"GKFBDCHILOD": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"GLEGPFFPDBE": [
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"SubRooms",
|
||||
"Roles",
|
||||
"Tags",
|
||||
"LoadScreens",
|
||||
"PromoImages",
|
||||
"PromoExternalContent",
|
||||
"RoomId",
|
||||
"IsDorm",
|
||||
"MaxPlayerCalculationMode",
|
||||
"MaxPlayers",
|
||||
"PersistenceVersion",
|
||||
"CloningAllowed",
|
||||
"DisableMicAutoMute",
|
||||
"DisableRoomComments",
|
||||
"EncryptVoiceChat",
|
||||
"ToxmodEnabled",
|
||||
"LoadScreenLocked",
|
||||
"IsDeveloperOwned",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"GLENKGBDMAO": [
|
||||
"Result",
|
||||
"Code",
|
||||
"Url",
|
||||
"RedemptionUrl"
|
||||
],
|
||||
"GMBGBPNMGBA": [
|
||||
"Status",
|
||||
"Invention",
|
||||
"InventionVersion"
|
||||
],
|
||||
"GMDIDJMOEBB": [
|
||||
"Objectives",
|
||||
"ObjectiveGroups"
|
||||
],
|
||||
"GMFJIAMOIEO": [
|
||||
"SubscriptionId",
|
||||
"RecNetPlayerId",
|
||||
"PlatformType",
|
||||
"PlatformId",
|
||||
"PlatformPurchaseId",
|
||||
"Level",
|
||||
"Period",
|
||||
"ExpirationDate",
|
||||
"IsAutoRenewing",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"GMNMHECGEGD": [
|
||||
"CurrencyType",
|
||||
"BalanceAdds"
|
||||
],
|
||||
"GNIPAKCCOOE": [
|
||||
"ProgressionEventId",
|
||||
"Name",
|
||||
"Rewards",
|
||||
"KeepsakeRoomLists",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"CollectionEndTime",
|
||||
"UsesBoost",
|
||||
"BoostDailyGameplayMinutesLimit",
|
||||
"BoostXpMultiplier",
|
||||
"PurchasableXpBoostId",
|
||||
"ActiveExperiment",
|
||||
"ChallengesIconImageName",
|
||||
"RewardsPipImageName",
|
||||
"EventInfoImageName"
|
||||
],
|
||||
"GNMDEJJAHCJ": [
|
||||
"SubscriberDiscountPercent",
|
||||
"StorefrontType",
|
||||
"NextUpdate",
|
||||
"NewUntil",
|
||||
"StoreItems"
|
||||
],
|
||||
"GOHGJADGDJF": [
|
||||
"ReplicationId",
|
||||
"DataBlob",
|
||||
"SubRooms",
|
||||
"Tags",
|
||||
"UnityAssets",
|
||||
"RoomId",
|
||||
"IsDorm",
|
||||
"MaxPlayerCalculationMode",
|
||||
"MaxPlayers",
|
||||
"PersistenceVersion",
|
||||
"CloningAllowed",
|
||||
"DisableMicAutoMute",
|
||||
"DisableRoomComments",
|
||||
"EncryptVoiceChat",
|
||||
"ToxmodEnabled",
|
||||
"LoadScreenLocked",
|
||||
"IsDeveloperOwned",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"GUISkin": [
|
||||
"HorizontalSliderThumbExtent",
|
||||
"VerticalSliderThumbExtent"
|
||||
],
|
||||
"HADOHMCCLEO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HBKAFCAEFCC": [
|
||||
"CreatedAt",
|
||||
"Comment"
|
||||
],
|
||||
"HDLOMAGLILM": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HDOJMEBCAGP": [
|
||||
"Permission",
|
||||
"Role",
|
||||
"Override",
|
||||
"Type",
|
||||
"Value"
|
||||
],
|
||||
"HECILPKCKMK": [
|
||||
"FeaturedRoomGroupId",
|
||||
"Name",
|
||||
"Rooms"
|
||||
],
|
||||
"HEIPMIFEFEE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HEKMOMJFFGJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HFHNOHAODOF": [
|
||||
"ValidTill",
|
||||
"Images"
|
||||
],
|
||||
"HFODJDLIJPG": [
|
||||
"TargetPlayerId",
|
||||
"RoomName",
|
||||
"ActionCode"
|
||||
],
|
||||
"HGJDGKFIOCK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HHJLCKPJOIA": [
|
||||
"PlayerId",
|
||||
"StatusVisibility",
|
||||
"DeviceClass",
|
||||
"VrMovementMode",
|
||||
"RoomInstance",
|
||||
"IsOnline",
|
||||
"AppVersion",
|
||||
"ClientJoinData"
|
||||
],
|
||||
"HHOJLJGGNKN": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HIKOJENMDKE": [
|
||||
"KeepsakeCategoryId",
|
||||
"VisualId",
|
||||
"LimitPerRoom",
|
||||
"XpValue",
|
||||
"IconOutlineImageName",
|
||||
"IconFilledImageName"
|
||||
],
|
||||
"HIOJNFKGAOK": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HIOPBEPNFOO": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HJKLIIPPPEM": [
|
||||
"PlayerEvent",
|
||||
"PlayerEventResponse"
|
||||
],
|
||||
"HJOIJBMAIPO": [
|
||||
"PlaylistId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"HJOKFJKKEJM": [
|
||||
"BalanceUpdateResult",
|
||||
"TokenBalanceResponse",
|
||||
"OperationResult"
|
||||
],
|
||||
"HJPEABLEEKL": [
|
||||
"RoomConsumableId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"PriceAndCurrency"
|
||||
],
|
||||
"HKAGNIGNBOJ": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HKIJINJGBGD": [
|
||||
"Type",
|
||||
"User_id",
|
||||
"Identifiers",
|
||||
"Ip",
|
||||
"Gameplay_session_id",
|
||||
"Transaction_id"
|
||||
],
|
||||
"HKPKGOPHLHE": [
|
||||
"RoomInstanceCount",
|
||||
"PlayerCount"
|
||||
],
|
||||
"HLDLKOPNDPF": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HMDONHHKKGA": [
|
||||
"PinnedFilters",
|
||||
"PopularFilters",
|
||||
"TrendingFilters"
|
||||
],
|
||||
"HMGHGBMGNJG": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HMKMGPNMHBM": [
|
||||
"Instances",
|
||||
"CollectionRecords",
|
||||
"KeepsakeProgressionEventIds"
|
||||
],
|
||||
"HMMFNEEAFGP": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"HNLGHDMNMDB": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"HOGIMIPDDGP": [
|
||||
"ImageName",
|
||||
"ImageUrl"
|
||||
],
|
||||
"HPAKKJFOFDF": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HPEGLGGMNMC": [
|
||||
"ChatThread",
|
||||
"ChatResult"
|
||||
],
|
||||
"HPMMKFGDAEC": [
|
||||
"PlayerEventId",
|
||||
"CreatorPlayerId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"AttendeeCount",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions",
|
||||
"BroadcastingRoomInstanceId"
|
||||
],
|
||||
"IACBFJEMJMJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"IAPMDDFENDD": [
|
||||
"PurchasableItemId",
|
||||
"CouponConsumablePlayerMappingId",
|
||||
"Count",
|
||||
"CreatorPlayerId",
|
||||
"RequestedPrice"
|
||||
],
|
||||
"IBAPHBCOBHL": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"IBILPLGNAJE": [
|
||||
"Id",
|
||||
"ImageName",
|
||||
"PlayerId",
|
||||
"RoomId",
|
||||
"PlayerEventId",
|
||||
"Accessibility",
|
||||
"AccessibilityLocked",
|
||||
"Type",
|
||||
"CreatedAt",
|
||||
"TaggedPlayerIds",
|
||||
"CheerCount",
|
||||
"CommentCount"
|
||||
],
|
||||
"ICMBGNCJMJG": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"ICNLODNGFJJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"IDBFKCFEAHM": [
|
||||
"BalanceUpdateResult",
|
||||
"CurrencyBalanceResponse",
|
||||
"OperationResult"
|
||||
],
|
||||
"IDBLMMAGACO": [
|
||||
"Key",
|
||||
"Value"
|
||||
],
|
||||
"IDFEIMCEMCC": [
|
||||
"Level",
|
||||
"Period",
|
||||
"IsAutoRenewing"
|
||||
],
|
||||
"IEIKHFIPDMD": [
|
||||
"CurrencyType",
|
||||
"Price",
|
||||
"StorefrontSaleData"
|
||||
],
|
||||
"IFFEHJEPKOH": [
|
||||
"ReferralsVerifiedCount",
|
||||
"PlayerReferralRewards"
|
||||
],
|
||||
"IFLDEEGLKFG": [
|
||||
"Level",
|
||||
"RequiredXp",
|
||||
"GiftDropId"
|
||||
],
|
||||
"IGMCJEFBJCF": [
|
||||
"ServerTime",
|
||||
"PlayerId",
|
||||
"StatusVisibility",
|
||||
"DeviceClass",
|
||||
"VrMovementMode",
|
||||
"RoomInstance",
|
||||
"IsOnline",
|
||||
"AppVersion",
|
||||
"ClientJoinData"
|
||||
],
|
||||
"IGNECOJDECH": [
|
||||
"Info",
|
||||
"ReferrerId"
|
||||
],
|
||||
"IHGDKHPLIGH": [
|
||||
"RoomId",
|
||||
"RoomName",
|
||||
"ImageName"
|
||||
],
|
||||
"IJFKOMJBJMI": [
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"StatValue",
|
||||
"CurrentStatValue"
|
||||
],
|
||||
"ILAHMLLJDLE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"ILEHDPADJJF": [
|
||||
"Nouns",
|
||||
"Adjectives"
|
||||
],
|
||||
"ILJOBJDPBNJ": [
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"BaseAvatarItemId",
|
||||
"BaseAvatarItemColor",
|
||||
"Accessibility"
|
||||
],
|
||||
"IMMOJHBENFI": [
|
||||
"Slot",
|
||||
"PreviewImageName",
|
||||
"OutfitSelections",
|
||||
"OutfitSelectionsV2",
|
||||
"FaceFeatures",
|
||||
"SkinColor",
|
||||
"HairColor",
|
||||
"CustomAvatarItems"
|
||||
],
|
||||
"INLPIKBODME": [
|
||||
"CreatorAccountId",
|
||||
"BaseAvatarItemId",
|
||||
"DesignFilename",
|
||||
"BaseAvatarItemColor"
|
||||
],
|
||||
"IOEPPCKGBFL": [
|
||||
"InventionId",
|
||||
"ReplicationId",
|
||||
"CreatorPlayerId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"CurrentVersionNumber",
|
||||
"Accessibility",
|
||||
"ModifiedAt",
|
||||
"CreatedAt",
|
||||
"FirstPublishedAt",
|
||||
"CreationRoomId",
|
||||
"NumPlayersHaveUsedInRoom",
|
||||
"NumDownloads",
|
||||
"CheerCount",
|
||||
"CreatorPermission",
|
||||
"GeneralPermission",
|
||||
"IsAGInvention",
|
||||
"IsCertifiedInvention",
|
||||
"AllowTrial",
|
||||
"Price",
|
||||
"HideFromPlayer"
|
||||
],
|
||||
"IPMGNJMIONC": [
|
||||
"Revenue_currency",
|
||||
"Revenue_amount",
|
||||
"Revenue_type",
|
||||
"Type",
|
||||
"User_id",
|
||||
"Identifiers",
|
||||
"Ip",
|
||||
"Gameplay_session_id",
|
||||
"Transaction_id"
|
||||
],
|
||||
"JAFDEPHGOAF": [
|
||||
"Title",
|
||||
"Message",
|
||||
"ImageName",
|
||||
"ActiveExperiments",
|
||||
"Context",
|
||||
"InputType",
|
||||
"Visibility",
|
||||
"AllowCycling",
|
||||
"PlatformMask",
|
||||
"RestrictToNewUsers",
|
||||
"RoomNames"
|
||||
],
|
||||
"JAHJGFHFKIB": [
|
||||
"Club",
|
||||
"CustomTags",
|
||||
"AdditionalImages",
|
||||
"CoownerPermissions",
|
||||
"ModeratorPermissions",
|
||||
"MemberPermissions",
|
||||
"MyMembershipType"
|
||||
],
|
||||
"JBGKGCLPCHP": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"JBIEJIKHBMG": [
|
||||
"SeedRoom",
|
||||
"Rooms"
|
||||
],
|
||||
"JCPFCOEEELK": [
|
||||
"AccountId",
|
||||
"CurrencyId",
|
||||
"Success",
|
||||
"Error",
|
||||
"Response"
|
||||
],
|
||||
"JDEKAFDPGBE": [
|
||||
"ReplicationId",
|
||||
"PersistenceVersion",
|
||||
"SupportsJoinInProgress",
|
||||
"UseLevelBasedMatchmaking",
|
||||
"UseAgeBasedMatchmaking",
|
||||
"UseRecRoyaleMatchmaking",
|
||||
"SubRoomId",
|
||||
"RoomId",
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"UnityAssetId",
|
||||
"UnityAsset",
|
||||
"UnityAssetHash",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"DataSavedAt",
|
||||
"IsSandbox",
|
||||
"MaxPlayers",
|
||||
"Accessibility"
|
||||
],
|
||||
"JEBCHNDADKK": [
|
||||
"ConcurrencyCodes",
|
||||
"ExpectedPriceAndCurrency"
|
||||
],
|
||||
"JFEKPNOKFBP": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JGJFCFAFGCM": [
|
||||
"TotalXP",
|
||||
"Level",
|
||||
"RankIdx",
|
||||
"RankName",
|
||||
"CurrentLevelXPThreshold",
|
||||
"NextLevelXPThreshold",
|
||||
"NextLevelAcornReward"
|
||||
],
|
||||
"JHNAJMDHFKH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JICIOMDBPDO": [
|
||||
"Id",
|
||||
"ConsumableItemDesc",
|
||||
"Count",
|
||||
"InitialCount",
|
||||
"CreatedAt",
|
||||
"ActiveDurationMinutes",
|
||||
"IsActive",
|
||||
"IsTransferable"
|
||||
],
|
||||
"JIOEIEENGNO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JJEKBGHDCEG": [
|
||||
"Tier",
|
||||
"Rewards",
|
||||
"PurchasableItemId",
|
||||
"Type",
|
||||
"Prices",
|
||||
"SubscriberPrices",
|
||||
"IsFeatured",
|
||||
"AvailableAt",
|
||||
"AvailableUntil",
|
||||
"NewUntil"
|
||||
],
|
||||
"JJGEJHCFEJH": [
|
||||
"GiftDropId",
|
||||
"ConsumableItemDesc",
|
||||
"AvatarItemDesc",
|
||||
"AvatarItemType",
|
||||
"EquipmentPrefabName",
|
||||
"EquipmentModificationGuid",
|
||||
"StorefrontType",
|
||||
"Xp",
|
||||
"Level",
|
||||
"GiftContext",
|
||||
"GiftRarity"
|
||||
],
|
||||
"JKJHOAKNOCF": [
|
||||
"CurrentConcurrencyCode",
|
||||
"NewConcurrencyCode"
|
||||
],
|
||||
"JLIBBFMMLFF": [
|
||||
"CurrencyType",
|
||||
"BalanceAddType",
|
||||
"BaseAward",
|
||||
"BonusAwardMin",
|
||||
"BonusAwardMax",
|
||||
"RateLimitType",
|
||||
"IgnorePartialMultiplier",
|
||||
"MaxPartialMultiplier",
|
||||
"RateLimit",
|
||||
"BalanceInGiftBox"
|
||||
],
|
||||
"JLNFEFHNFBP": [
|
||||
"Title",
|
||||
"Description"
|
||||
],
|
||||
"JLNKMODEKKM": [
|
||||
"BaseAvatarItemId",
|
||||
"BaseAvatarItemColor"
|
||||
],
|
||||
"JMJLMOJCBCK": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"JNFDPPCIPHG": [
|
||||
"CategoryId",
|
||||
"Importance",
|
||||
"Name",
|
||||
"Description",
|
||||
"IsMuteable"
|
||||
],
|
||||
"JOMMALIPDGI": [
|
||||
"RoomId",
|
||||
"IsDorm",
|
||||
"MaxPlayerCalculationMode",
|
||||
"MaxPlayers",
|
||||
"PersistenceVersion",
|
||||
"CloningAllowed",
|
||||
"DisableMicAutoMute",
|
||||
"DisableRoomComments",
|
||||
"EncryptVoiceChat",
|
||||
"ToxmodEnabled",
|
||||
"LoadScreenLocked",
|
||||
"IsDeveloperOwned",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"JPKBLKGJNCA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JPLIIMKJAEL": [
|
||||
"AccountId",
|
||||
"DisallowInAppPurchases"
|
||||
],
|
||||
"JPNMBICAMEE": [
|
||||
"RoomInstanceId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"IsFull",
|
||||
"CreatedAt",
|
||||
"PlayerIds"
|
||||
],
|
||||
"JsonParser_NestedType_JsonValue": [
|
||||
"False",
|
||||
"True"
|
||||
],
|
||||
"KAONMCEONPF": [
|
||||
"Name",
|
||||
"Price",
|
||||
"ProductId"
|
||||
],
|
||||
"KBHFIPFPCMN": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KBLOCDGCGFA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KBMAAKAIPLE": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"KCMDEFMLDJF": [
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"BalanceType",
|
||||
"Data"
|
||||
],
|
||||
"KDAHIOHGPBN": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"KDJILJAIDFG": [
|
||||
"RoomId",
|
||||
"Count"
|
||||
],
|
||||
"KEOLHAONMIF": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"KFAOOJLFCGC": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KFPHOKPEMOF": [
|
||||
"ItemType",
|
||||
"ItemId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"RoomId",
|
||||
"Price",
|
||||
"PurchaseCurrencyId",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"KGNFNBHMAFL": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"KHBGNAMFAHK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KHFKIJEGPDG": [
|
||||
"AccountId",
|
||||
"RoomKeyId",
|
||||
"DoesPlayerOwnRoomKey"
|
||||
],
|
||||
"KIANAKFJGCP": [
|
||||
"FeaturedPlayer",
|
||||
"FeaturedRoomGroup",
|
||||
"CurrentAnnouncement",
|
||||
"InstagramImages",
|
||||
"Videos"
|
||||
],
|
||||
"KILGEFOENFN": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"KJEIGGBIIHP": [
|
||||
"SavedImageId",
|
||||
"IsCheered"
|
||||
],
|
||||
"KKOHONLGIHL": [
|
||||
"Id",
|
||||
"FromPlayerId",
|
||||
"SentTime",
|
||||
"Type",
|
||||
"Data",
|
||||
"RoomId",
|
||||
"PlayerEventId"
|
||||
],
|
||||
"KLHCKBPJKPF_NestedType_DHKPMMHMIJF_NestedType_KKNKAJBPNPC": [
|
||||
"SerializeUnsafe",
|
||||
"ToJsonString"
|
||||
],
|
||||
"KMBHAKAHNGH": [
|
||||
"Room",
|
||||
"SubRoomDataSave"
|
||||
],
|
||||
"KMHNOGNHDLO": [
|
||||
"SubscriptionSeasonMilestoneId",
|
||||
"SeasonId",
|
||||
"StartAt",
|
||||
"GiftDrops"
|
||||
],
|
||||
"KNGMIABFJHK": [
|
||||
"CurrencyBalanceResponse",
|
||||
"TokenBalanceResponse"
|
||||
],
|
||||
"KOEEACLMAFE": [
|
||||
"Referrals",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"KPEIHOCIGGE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KPMMGJELBPP": [
|
||||
"WindowSize",
|
||||
"PlayerId",
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"FilterType",
|
||||
"SortAscending"
|
||||
],
|
||||
"KPNBKKKCOCA": [
|
||||
"UnlockedAt",
|
||||
"ClaimedAt",
|
||||
"Status",
|
||||
"ReferralRewardTier",
|
||||
"IsCurrentTier"
|
||||
],
|
||||
"KPPINBHEKHE": [
|
||||
"Products",
|
||||
"Identifier",
|
||||
"Genuine",
|
||||
"GenuineCheckAvailable"
|
||||
],
|
||||
"LAIJIAIHGCO": [
|
||||
"ReportCategory",
|
||||
"Duration",
|
||||
"GameSessionId",
|
||||
"IsHostKick",
|
||||
"Message",
|
||||
"PlayerIdReporter",
|
||||
"IsBan",
|
||||
"IsVoiceModAutoban",
|
||||
"IsWarning",
|
||||
"VoteKickReason",
|
||||
"TimeoutStartedAt"
|
||||
],
|
||||
"LBOGFNBPIKJ": [
|
||||
"SubRoomId",
|
||||
"RoomId",
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"UnityAssetId",
|
||||
"UnityAsset",
|
||||
"UnityAssetHash",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"DataSavedAt",
|
||||
"IsSandbox",
|
||||
"MaxPlayers",
|
||||
"Accessibility"
|
||||
],
|
||||
"LDGHHCCJAMC": [
|
||||
"Status",
|
||||
"RoomKey"
|
||||
],
|
||||
"LEEAJGDIOHI": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LEINHCHGFON": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"LEMIBENHABD": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LIJHBNDJKNP": [
|
||||
"RewardSelectionId",
|
||||
"RewardType",
|
||||
"Message",
|
||||
"GiftContext",
|
||||
"GiftDrop1",
|
||||
"GiftDrop2",
|
||||
"GiftDrop3",
|
||||
"Subscriber_GiftDrop3",
|
||||
"CreatedAt"
|
||||
],
|
||||
"LIODMDILCAJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LKBGCOELLIN": [
|
||||
"CurrencyId",
|
||||
"PurchaseOffers"
|
||||
],
|
||||
"LKJLPNENINM": [
|
||||
"RoomKeyResponse",
|
||||
"BalanceUpdateResponse"
|
||||
],
|
||||
"LKJPKMBEPCG": [
|
||||
"PrefabName",
|
||||
"ModificationGuid",
|
||||
"FriendlyName",
|
||||
"Tooltip",
|
||||
"Rarity",
|
||||
"Favorited"
|
||||
],
|
||||
"LKLKHEEHEBC": [
|
||||
"UnityAssetId",
|
||||
"Target",
|
||||
"Version",
|
||||
"Filename",
|
||||
"Hash"
|
||||
],
|
||||
"LKMEPPOFHLK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"LMCCNLLHLCJ": [
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LMEECKCCPHG": [
|
||||
"AccountId",
|
||||
"Role",
|
||||
"LastChangedByAccountId",
|
||||
"InvitedRole"
|
||||
],
|
||||
"LMHKHIJIDEO": [
|
||||
"SavedImageId",
|
||||
"Cheer"
|
||||
],
|
||||
"LNMAMOCCBAK": [
|
||||
"Filename",
|
||||
"UnityAssetId",
|
||||
"CreatedByAccountId",
|
||||
"BakedUnityAssets"
|
||||
],
|
||||
"LOEHGGMCKAM": [
|
||||
"ChatResult",
|
||||
"SnoozedUntil"
|
||||
],
|
||||
"LOOLOADLJPG": [
|
||||
"SubscriptionSeasonId",
|
||||
"Name",
|
||||
"ImageName",
|
||||
"StartAt",
|
||||
"NextSeasonStart",
|
||||
"Milestones"
|
||||
],
|
||||
"LPAEOGFPGBD": [
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"MaxPlayers",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile"
|
||||
],
|
||||
"LPFAFIGAIOE": [
|
||||
"Status",
|
||||
"InventoryItem"
|
||||
],
|
||||
"MACBLLENLFD": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"MADLMAFINGF": [
|
||||
"KeepsakeInstanceId",
|
||||
"KeepsakeCategoryConfigId",
|
||||
"PlacedByAccountId",
|
||||
"RoomId",
|
||||
"SubRoomId"
|
||||
],
|
||||
"MAHHAAEGEEP": [
|
||||
"InventionResponse",
|
||||
"BalanceUpdateResponse"
|
||||
],
|
||||
"MBFADJPOGDI": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MCDADMNCDEI": [
|
||||
"WishlistItemId",
|
||||
"AccountId",
|
||||
"PurchasableItemId",
|
||||
"CreatedAt"
|
||||
],
|
||||
"MCMPJPNBJBI": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MEBAFOGDHBB": [
|
||||
"PurchaseCampaignId",
|
||||
"PurchaseReminder",
|
||||
"Name",
|
||||
"TriggerFlags",
|
||||
"LastShown",
|
||||
"ShowCount",
|
||||
"DaysBetweenPopups",
|
||||
"MaxPopups"
|
||||
],
|
||||
"MEEAEDNAPGI": [
|
||||
"CommentId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"AccountId",
|
||||
"CreatedAt",
|
||||
"Message",
|
||||
"Style",
|
||||
"Unread",
|
||||
"PositionX",
|
||||
"PositionY",
|
||||
"PositionZ"
|
||||
],
|
||||
"MFNBIPCHKHC": [
|
||||
"MembershipRequests",
|
||||
"TotalMembershipRequests",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"MGOLBEMMNDA": [
|
||||
"BalanceAddType",
|
||||
"BaseAward",
|
||||
"BonusAward",
|
||||
"RateLimit",
|
||||
"CurrentCount",
|
||||
"Total",
|
||||
"Platform",
|
||||
"BalanceInGiftBox"
|
||||
],
|
||||
"MIFGPMGEMEK": [
|
||||
"RefreshesAt",
|
||||
"RefreshIntervalMinutes",
|
||||
"Room"
|
||||
],
|
||||
"MIHDILLIAFE": [
|
||||
"Instances",
|
||||
"CollectionRecords"
|
||||
],
|
||||
"MIHJPKMLADF": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MIHKAJFMIPE": [
|
||||
"RoomId",
|
||||
"CurrencyId"
|
||||
],
|
||||
"MJGHCJDOJLH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"MJJDCIAIDKI": [
|
||||
"HasEliteUpgrade",
|
||||
"HasEliteUpgradePlatformMask",
|
||||
"CurrentSeasonTier",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"MJKDDBKGNOP": [
|
||||
"ContactDetail",
|
||||
"AccountId",
|
||||
"UserName",
|
||||
"DisplayName",
|
||||
"DisplayEmoji",
|
||||
"ProfileImage",
|
||||
"BannerImage",
|
||||
"TreatAsJunior",
|
||||
"HasBirthday",
|
||||
"PersonalPronouns",
|
||||
"IdentityFlags",
|
||||
"CreatedAt",
|
||||
"IsJunior"
|
||||
],
|
||||
"MJNABIBJDMO": [
|
||||
"Order",
|
||||
"Objective",
|
||||
"Count"
|
||||
],
|
||||
"MKAIABAJIAJ": [
|
||||
"CustomAvatarItemId",
|
||||
"BodyPart"
|
||||
],
|
||||
"MKBLHNGJOBA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MLHBPDKBABN": [
|
||||
"CheerCount",
|
||||
"FavoriteCount",
|
||||
"VisitorCount",
|
||||
"VisitCount"
|
||||
],
|
||||
"MMDMDIJLIEH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MMFCMCPCAJP": [
|
||||
"ChatMessage",
|
||||
"ChatResult"
|
||||
],
|
||||
"MMPLMIAEBLE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MOJLGDKFECO": [
|
||||
"InvitedPlayerId",
|
||||
"Result"
|
||||
],
|
||||
"MOLNFPGNEGP": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"MOMDMDMGEOA": [
|
||||
"ItemType",
|
||||
"ItemId"
|
||||
],
|
||||
"Matchmaking": [
|
||||
"PlayerId",
|
||||
"RoomInstanceId"
|
||||
],
|
||||
"NAGNGCMPGOP": [
|
||||
"RoomId",
|
||||
"KeepsakeInstanceIds"
|
||||
],
|
||||
"NAPOKFHELLC": [
|
||||
"Multiplier",
|
||||
"BalanceAddType"
|
||||
],
|
||||
"NBGPICODFEE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"NCBBKAMCPBC": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"NCHBANKEJMP": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"NDIIFIGNAAM": [
|
||||
"Error",
|
||||
"Identifier",
|
||||
"Genuine",
|
||||
"GenuineCheckAvailable"
|
||||
],
|
||||
"NEJOPOKDPKE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"NEKEOJFNCCJ": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"NFBJOAGJHME": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"NGCPIKDINEI": [
|
||||
"Provider",
|
||||
"Data"
|
||||
],
|
||||
"NHGOFGABJIN": [
|
||||
"RoomInstanceId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"Location",
|
||||
"EventId",
|
||||
"ClubId",
|
||||
"RoomCode",
|
||||
"PhotonRegionId",
|
||||
"PhotonRoomId",
|
||||
"Name",
|
||||
"MaxCapacity",
|
||||
"IsFull",
|
||||
"IsPrivate",
|
||||
"IsInProgress",
|
||||
"EncryptVoiceChat",
|
||||
"RoomInstanceType"
|
||||
],
|
||||
"NIJPNDPJICF": [
|
||||
"Id",
|
||||
"Key",
|
||||
"Title",
|
||||
"Description",
|
||||
"RoomName",
|
||||
"Status",
|
||||
"AssignedPlayerNames",
|
||||
"Tags",
|
||||
"JiraUrl",
|
||||
"Comments"
|
||||
],
|
||||
"NKMEHOHCKOH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"NLIAAGLJBNN": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"NMGHOIMEJIK": [
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"Accessibility"
|
||||
],
|
||||
"NNAGOPNGJFE": [
|
||||
"AmplitudeKey",
|
||||
"RudderStackKey",
|
||||
"UseRudderStack",
|
||||
"StatSigKey"
|
||||
],
|
||||
"OANOOFEPJCI": [
|
||||
"Rooms",
|
||||
"Tags",
|
||||
"PlaylistId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"OBACBEGPKAK": [
|
||||
"ReportCategory",
|
||||
"Details",
|
||||
"ReportedPlayerId"
|
||||
],
|
||||
"OBGIGMCOMDE": [
|
||||
"AdCarouselItemId",
|
||||
"ImageName",
|
||||
"Title",
|
||||
"Description",
|
||||
"PurchasableItemIds",
|
||||
"PurchaseReminderId"
|
||||
],
|
||||
"OBIBPCLDPGA": [
|
||||
"Message",
|
||||
"MoreInfoUrl"
|
||||
],
|
||||
"OCFNOBAONAP": [
|
||||
"Cheered",
|
||||
"Favorited",
|
||||
"LastVisitedAt"
|
||||
],
|
||||
"OCGICPIMNEB": [
|
||||
"ErrorCode",
|
||||
"RoomInstance"
|
||||
],
|
||||
"ODNKLIIJIOH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"OEHMNIAOAOH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"OFGIPBHIAGF": [
|
||||
"ProgressionEventPurchasableXpBoostId",
|
||||
"Cost",
|
||||
"XpMultiplier",
|
||||
"XpCap",
|
||||
"LookbackDurationTicks",
|
||||
"CooldownDurationTicks"
|
||||
],
|
||||
"OFMNNCMPEPA": [
|
||||
"SessionId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"UnityAssetId",
|
||||
"RoomData",
|
||||
"SubRoomData",
|
||||
"SavedByAccountId"
|
||||
],
|
||||
"OGCMIDEFOEE": [
|
||||
"ChatResult",
|
||||
"IsFavorited"
|
||||
],
|
||||
"OGEDLCFDDIO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"OHNEMFHCBIJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"OKHGCKDIILD": [
|
||||
"ImageName",
|
||||
"Index"
|
||||
],
|
||||
"OLACHOEDKFB": [
|
||||
"ReportCategory",
|
||||
"YesVotes",
|
||||
"NoVotes",
|
||||
"PlayerIdReported"
|
||||
],
|
||||
"OLIHBGPDPHF": [
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"BalanceType",
|
||||
"Data"
|
||||
],
|
||||
"OLKCALBDDOI": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"OLPHKLCPFEF": [
|
||||
"InventionId",
|
||||
"ReplicationId",
|
||||
"VersionNumber",
|
||||
"InstantiationCost",
|
||||
"LightsCost",
|
||||
"ChipsCost",
|
||||
"CloudVariablesCost",
|
||||
"BlobName",
|
||||
"BlobHash"
|
||||
],
|
||||
"OMAPLLOIDNA": [
|
||||
"ToPlayerIds",
|
||||
"Message",
|
||||
"GiftContext"
|
||||
],
|
||||
"ONGJBLFBHGC": [
|
||||
"PurchasableItemId",
|
||||
"Type",
|
||||
"Prices",
|
||||
"SubscriberPrices",
|
||||
"IsFeatured",
|
||||
"AvailableAt",
|
||||
"AvailableUntil",
|
||||
"NewUntil"
|
||||
],
|
||||
"OOGKPMKKJBE": [
|
||||
"SalePercent",
|
||||
"SaleStartDate",
|
||||
"SaleEndDate"
|
||||
],
|
||||
"PAKGKIKFGAE": [
|
||||
"Id",
|
||||
"TitleOverride",
|
||||
"UrlOverride"
|
||||
],
|
||||
"PCBAACNNDBH": [
|
||||
"FriendlyImageName",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime"
|
||||
],
|
||||
"PCMLHLIBLNJ": [
|
||||
"Tag",
|
||||
"Type"
|
||||
],
|
||||
"PCODFCJECKL": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"PCPOMPIAFMA": [
|
||||
"Key",
|
||||
"Value",
|
||||
"StartTime",
|
||||
"EndTime"
|
||||
],
|
||||
"PDGENCNGCJI": [
|
||||
"RoomId",
|
||||
"Ids"
|
||||
],
|
||||
"PDIFGFKAMBG": [
|
||||
"PlayerCount",
|
||||
"IsFull",
|
||||
"PlayerEventId",
|
||||
"CreatorPlayerId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"AttendeeCount",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions",
|
||||
"BroadcastingRoomInstanceId"
|
||||
],
|
||||
"PECGEJAAMHB": [
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"PELEHJLMKJO": [
|
||||
"SubRoomDataSaveId",
|
||||
"SubRoomId",
|
||||
"UnityAssetId",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"SavedByAccountId",
|
||||
"SavedOnPlatform",
|
||||
"SavedOnDeviceClass",
|
||||
"Description",
|
||||
"CreatedAt"
|
||||
],
|
||||
"PEOAOMGOMGC": [
|
||||
"CurrencyId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"CurrencyType",
|
||||
"Limit",
|
||||
"ImageName",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"PGACEMBEGJF": [
|
||||
"ToPlayerId",
|
||||
"Message",
|
||||
"Anonymous",
|
||||
"GiftContext"
|
||||
],
|
||||
"PJPBJKLDMNA": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"PKCMBJFBHBO": [
|
||||
"TotalXp",
|
||||
"SocialBoostXp"
|
||||
],
|
||||
"PLLNLPADHCN": [
|
||||
"Resolution",
|
||||
"Language",
|
||||
"Timezone",
|
||||
"Platform"
|
||||
],
|
||||
"POGBAGAHGIA": [
|
||||
"GiftDrop",
|
||||
"PurchasableItemId",
|
||||
"Type",
|
||||
"Prices",
|
||||
"SubscriberPrices",
|
||||
"IsFeatured",
|
||||
"AvailableAt",
|
||||
"AvailableUntil",
|
||||
"NewUntil"
|
||||
],
|
||||
"PPHLKNGCGOE": [
|
||||
"SessionId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"UnityAssetId",
|
||||
"RoomDataFilename",
|
||||
"RoomDataHash",
|
||||
"SubRoomDataFilename",
|
||||
"SubRoomDataHash"
|
||||
],
|
||||
"PlayerInput": [
|
||||
"OnPlayerLeft",
|
||||
"OnDeviceLost",
|
||||
"OnDeviceRegained",
|
||||
"OnControlsChanged"
|
||||
],
|
||||
"PlayerInputManager": [
|
||||
"OnPlayerJoined",
|
||||
"OnPlayerLeft"
|
||||
],
|
||||
"StringHelpers": [
|
||||
"Mouse",
|
||||
"Mice",
|
||||
"Axis",
|
||||
"Axes"
|
||||
],
|
||||
"X509Name": [
|
||||
"C",
|
||||
"O",
|
||||
"T",
|
||||
"OU",
|
||||
"CN",
|
||||
"L",
|
||||
"ST",
|
||||
"SERIALNUMBER",
|
||||
"E",
|
||||
"DC",
|
||||
"UID",
|
||||
"STREET",
|
||||
"SURNAME",
|
||||
"GIVENNAME",
|
||||
"INITIALS",
|
||||
"GENERATION",
|
||||
"UniqueIdentifier",
|
||||
"DN",
|
||||
"Pseudonym",
|
||||
"PostalAddress",
|
||||
"CountryOfCitizenship",
|
||||
"CountryOfResidence",
|
||||
"Gender",
|
||||
"PlaceOfBirth",
|
||||
"DateOfBirth",
|
||||
"PostalCode",
|
||||
"BusinessCategory",
|
||||
"TelephoneNumber"
|
||||
],
|
||||
"XSDSchema": [
|
||||
"True",
|
||||
"False",
|
||||
"Name"
|
||||
],
|
||||
"XmlTreeGen": [
|
||||
"Namespace",
|
||||
"Unique",
|
||||
"Attribute"
|
||||
]
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
# Every HTTP request the Rec Room 2023-03-21 client can issue.
|
||||
# Recovered from the client binary; see docs/recroom-2023-client-api-complete.md.
|
||||
# verb<TAB>route<TAB>subsystem
|
||||
GET account/bulk identity-account
|
||||
POST account/bulk identity-account
|
||||
GET account/isactivecreator/me identity-account
|
||||
DELETE account/me identity-account
|
||||
GET account/me identity-account
|
||||
POST account/me/changepassword identity-account
|
||||
POST account/me/createlogintoken identity-account
|
||||
GET account/me/haspassword identity-account
|
||||
POST account/me/remoteauth identity-account
|
||||
POST account/recoverpassword identity-account
|
||||
GET account/search identity-account
|
||||
GET account/{0}/bio identity-account
|
||||
GET account/{0}/clubs identity-account
|
||||
PUT accountprivacysettings/recenthistoryvisibility identity-account
|
||||
GET accountprivacysettings/{0} identity-account
|
||||
GET accounts/{0}/receives/{1} identity-account
|
||||
POST actionlink config-telemetry
|
||||
GET actionlink/{code} config-telemetry
|
||||
POST actionlink/{code}/consume config-telemetry
|
||||
GET announcements/club/{clubId} config-telemetry
|
||||
POST announcements/club/{clubId} config-telemetry
|
||||
DELETE announcements/club/{clubId}/{announcementId} config-telemetry
|
||||
PUT announcements/club/{clubId}/{announcementId} config-telemetry
|
||||
POST announcements/club/{clubId}/{announcementId}/read config-telemetry
|
||||
GET announcements/mine config-telemetry
|
||||
GET announcements/subscription/mine config-telemetry
|
||||
GET announcements/v2/mine/unread config-telemetry
|
||||
GET announcements/v2/mine/unread?sendAnnouncements=true config-telemetry
|
||||
POST api/AppIntegrity/v1/iospaymentqueuefailed commerce-subscription
|
||||
POST api/AppIntegrity/v1/iosproducts commerce-subscription
|
||||
GET api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers commerce-subscription
|
||||
POST api/CampusCard/v1/UpdateAndGetSubscription commerce-subscription
|
||||
POST api/PlayerCheer/v1/SetSelectedCheer progression-misc
|
||||
POST api/PlayerCheer/v1/create progression-misc
|
||||
POST api/PlayerReporting/v1/deviceId moderation-safety
|
||||
POST api/PlayerReporting/v1/hile moderation-safety
|
||||
POST api/PlayerReporting/v1/instantKick moderation-safety
|
||||
GET api/PlayerReporting/v1/moderationBlockDetails moderation-safety
|
||||
GET api/PlayerReporting/v1/voteToKickReasons moderation-safety
|
||||
POST api/PlayerReporting/v3/create moderation-safety
|
||||
POST api/PlayerReporting/v3/voteToKick moderation-safety
|
||||
GET api/activities/charades/v1/words/{0} progression-misc
|
||||
POST api/ageverification/generateCode moderation-safety
|
||||
GET api/announcement/v1/get config-telemetry
|
||||
GET api/apple/musicpromotion/active commerce-subscription
|
||||
GET api/apple/musicpromotion/code commerce-subscription
|
||||
GET api/avatar/v1/defaultbaseavataritems avatar-inventory
|
||||
GET api/avatar/v1/defaultunlocked avatar-inventory
|
||||
GET api/avatar/v1/lockeditems avatar-inventory
|
||||
GET api/avatar/v2 avatar-inventory
|
||||
GET api/avatar/v2/gifts avatar-inventory
|
||||
POST api/avatar/v2/gifts/generate avatar-inventory
|
||||
POST api/avatar/v2/set avatar-inventory
|
||||
GET api/avatar/v2/{playerId} avatar-inventory
|
||||
POST api/avatar/v3/gifts/generate avatar-inventory
|
||||
GET api/avatar/v3/saved avatar-inventory
|
||||
POST api/avatar/v3/saved/set avatar-inventory
|
||||
GET api/avatar/v4/items avatar-inventory
|
||||
GET api/banappeal/generateCode moderation-safety
|
||||
GET api/catalog/v1/all?onlyAvailableSkus=true avatar-inventory
|
||||
GET api/challenge/v2/getCurrent progression-misc
|
||||
POST api/challenge/v2/updateProgress progression-misc
|
||||
POST api/checklist/v1/complete progression-misc
|
||||
GET api/checklist/v1/current progression-misc
|
||||
POST api/clubreporting/v1/report moderation-safety
|
||||
GET api/communityboard/v2/current progression-misc
|
||||
GET api/config/v1/amplitude config-telemetry
|
||||
GET api/config/v1/azurespeech config-telemetry
|
||||
GET api/config/v1/backtrace config-telemetry
|
||||
GET api/config/v1/cohortnux/{cohortId} config-telemetry
|
||||
GET api/config/v1/freegiftbutton config-telemetry
|
||||
GET api/config/v2 config-telemetry
|
||||
POST api/consumables/v1/consume avatar-inventory
|
||||
GET api/consumables/v1/getTransferable/{playerId} avatar-inventory
|
||||
POST api/consumables/v1/transfer avatar-inventory
|
||||
POST api/consumables/v1/updateActive avatar-inventory
|
||||
GET api/consumables/v2/getUnlocked avatar-inventory
|
||||
POST api/customAvatarItems/v1 avatar-inventory
|
||||
POST api/customAvatarItems/v1/bulk avatar-inventory
|
||||
DELETE api/customAvatarItems/v1/design avatar-inventory
|
||||
GET api/customAvatarItems/v1/design avatar-inventory
|
||||
PUT api/customAvatarItems/v1/design avatar-inventory
|
||||
GET api/customAvatarItems/v1/featured avatar-inventory
|
||||
GET api/customAvatarItems/v1/hot avatar-inventory
|
||||
GET api/customAvatarItems/v1/isCreationAllowedForAccount avatar-inventory
|
||||
GET api/customAvatarItems/v1/isCreationEnabled avatar-inventory
|
||||
GET api/customAvatarItems/v1/isRenderingEnabled avatar-inventory
|
||||
GET api/customAvatarItems/v1/me avatar-inventory
|
||||
GET api/customAvatarItems/v1/minPriceForPublicItem avatar-inventory
|
||||
DELETE api/customAvatarItems/v1/{id} avatar-inventory
|
||||
PUT api/customAvatarItems/v1/{id} avatar-inventory
|
||||
POST api/customAvatarItems/v1/{id}/report avatar-inventory
|
||||
GET api/customAvatarItems/v2/fromCreator/{creatorId} avatar-inventory
|
||||
POST api/equipment/v1/update avatar-inventory
|
||||
GET api/equipment/v2/getUnlocked avatar-inventory
|
||||
POST api/externalfriendinvite/v1/createplatforminvite relationships-messaging
|
||||
POST api/externalfriendinvite/v1/getplatformreferrers relationships-messaging
|
||||
POST api/externalfriendinvite/v1/gettextmessagereferrers relationships-messaging
|
||||
POST api/externalfriendinvite/v1/sendtextmessageinvite relationships-messaging
|
||||
POST api/freegifts/v1/sendmultiple progression-misc
|
||||
GET api/gameconfigs/v1/all config-telemetry
|
||||
GET api/gamerewards/v1/pending progression-misc
|
||||
POST api/gamerewards/v1/request progression-misc
|
||||
POST api/gamerewards/v1/select progression-misc
|
||||
POST api/gamesight/event events-playlists
|
||||
POST api/images/v1/cheer inventions-media
|
||||
POST api/images/v1/deletesaved inventions-media
|
||||
GET api/images/v1/listsaved inventions-media
|
||||
POST api/images/v1/modifyaccessibility inventions-media
|
||||
POST api/images/v1/sendlink inventions-media
|
||||
GET api/images/v1/slideshow inventions-media
|
||||
POST api/images/v1/{id}/report inventions-media
|
||||
GET api/images/v2/named inventions-media
|
||||
GET api/images/v4/room/{roomId}?sort&filter&take&skip inventions-media
|
||||
POST api/images/v4/uploadsaved inventions-media
|
||||
GET api/images/v5/bulk inventions-media
|
||||
GET api/images/v5/cheered/bulk inventions-media
|
||||
GET api/images/v5/player/{playerId}?sort={int} inventions-media
|
||||
GET api/images/v6/{id} inventions-media
|
||||
GET api/images/v6?name={imageName} inventions-media
|
||||
POST api/incentivizedreferrals/claim progression-misc
|
||||
GET api/incentivizedreferrals/progress progression-misc
|
||||
GET api/influencerpartnerprogram/influencer progression-misc
|
||||
GET api/influencerpartnerprogram/influencers progression-misc
|
||||
GET api/influencerpartnerprogram/myinfluencer progression-misc
|
||||
POST api/influencerpartnerprogram/remove progression-misc
|
||||
POST api/influencerpartnerprogram/support progression-misc
|
||||
POST api/inventions/v1/cheer inventions-media
|
||||
GET api/inventions/v1/delete?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/details?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/dormskinsfromids inventions-media
|
||||
GET api/inventions/v1/featured inventions-media
|
||||
GET api/inventions/v1/featureddormskins inventions-media
|
||||
GET api/inventions/v1/fromcreators inventions-media
|
||||
GET api/inventions/v1/fulllineageowner inventions-media
|
||||
GET api/inventions/v1/personaldetails/{id} inventions-media
|
||||
POST api/inventions/v1/report inventions-media
|
||||
GET api/inventions/v1/room?id={roomId} inventions-media
|
||||
POST api/inventions/v1/settags inventions-media
|
||||
GET api/inventions/v1/tagfilters inventions-media
|
||||
GET api/inventions/v1/toptoday inventions-media
|
||||
GET api/inventions/v1/unpublish?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/update inventions-media
|
||||
POST api/inventions/v1/updateprice inventions-media
|
||||
GET api/inventions/v1/version?inventionId={id}&version={n} inventions-media
|
||||
GET api/inventions/v1/versions?inventionId={id} inventions-media
|
||||
GET api/inventions/v1?inventionId={id} inventions-media
|
||||
GET api/inventions/v2/batch inventions-media
|
||||
GET api/inventions/v2/mine inventions-media
|
||||
GET api/inventions/v2/search?value&skip&take inventions-media
|
||||
GET api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price inventions-media
|
||||
POST api/inventions/v4/addversion inventions-media
|
||||
POST api/inventions/v6/save inventions-media
|
||||
POST api/keepsakes events-playlists
|
||||
GET api/keepsakes/categories events-playlists
|
||||
GET api/keepsakes/events/{progressionEventId} events-playlists
|
||||
GET api/keepsakes/globalconfig events-playlists
|
||||
GET api/keepsakes/rooms/{roomId} events-playlists
|
||||
DELETE api/keepsakes/{keepsakeInstanceId:guid} events-playlists
|
||||
POST api/keepsakes/{keepsakeInstanceId:guid}/collect events-playlists
|
||||
GET api/messages/v1/favoriteFriendOnlineStatus relationships-messaging
|
||||
POST api/messages/v1/sendMultiple relationships-messaging
|
||||
GET api/messages/v2/get relationships-messaging
|
||||
POST api/messages/v2/send relationships-messaging
|
||||
POST api/messages/v3/delete relationships-messaging
|
||||
POST api/objectives/v1/cleargroup progression-misc
|
||||
POST api/objectives/v1/completegroup progression-misc
|
||||
GET api/objectives/v1/myprogress progression-misc
|
||||
POST api/objectives/v1/updateobjective progression-misc
|
||||
POST api/offlineinvite/v1/send relationships-messaging
|
||||
GET api/playerevents/v1 events-playlists
|
||||
GET api/playerevents/v1/all events-playlists
|
||||
GET api/playerevents/v1/all/{0} events-playlists
|
||||
POST api/playerevents/v1/broadcast events-playlists
|
||||
POST api/playerevents/v1/bulk events-playlists
|
||||
POST api/playerevents/v1/bulkInvite events-playlists
|
||||
GET api/playerevents/v1/club/{0} events-playlists
|
||||
GET api/playerevents/v1/clubs events-playlists
|
||||
POST api/playerevents/v1/deleteResponse events-playlists
|
||||
POST api/playerevents/v1/report events-playlists
|
||||
POST api/playerevents/v1/respond events-playlists
|
||||
GET api/playerevents/v1/room/{0} events-playlists
|
||||
GET api/playerevents/v1/search events-playlists
|
||||
GET api/playerevents/v1/searchlive events-playlists
|
||||
GET api/playerevents/v1/tagfilters events-playlists
|
||||
GET api/playerevents/v1/{0} events-playlists
|
||||
GET api/playerevents/v1/{0}/responses events-playlists
|
||||
POST api/playerevents/v2 events-playlists
|
||||
POST api/playerevents/v2/delete/{0} events-playlists
|
||||
POST api/playerevents/v2/{0} events-playlists
|
||||
PUT api/playerevents/v2/{0}/accessibility events-playlists
|
||||
PUT api/playerevents/v2/{0}/club events-playlists
|
||||
PUT api/playerevents/v2/{0}/description events-playlists
|
||||
PUT api/playerevents/v2/{0}/image events-playlists
|
||||
PUT api/playerevents/v2/{0}/multiinstance events-playlists
|
||||
PUT api/playerevents/v2/{0}/name events-playlists
|
||||
PUT api/playerevents/v2/{0}/room events-playlists
|
||||
PUT api/playerevents/v2/{0}/tags events-playlists
|
||||
PUT api/playerevents/v2/{0}/time events-playlists
|
||||
PUT api/players/v4/current/contact identity-account
|
||||
POST api/playerwarnings moderation-safety
|
||||
POST api/playerwarnings/acknowledge moderation-safety
|
||||
POST api/playstationplus/expire commerce-subscription
|
||||
GET api/progressionEvents/active events-playlists
|
||||
POST api/progressionEvents/collect/{eventId}/{rewardIndex} events-playlists
|
||||
GET api/progressionEvents/event/{eventId} events-playlists
|
||||
GET api/progressionEvents/record/{progressionEventId} events-playlists
|
||||
GET api/progressionEvents/{eventId}/xpboosts events-playlists
|
||||
GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp events-playlists
|
||||
GET api/quickPlay/v1/getandclear progression-misc
|
||||
GET api/relationships/mutualfriends relationships-messaging
|
||||
POST api/relationships/sendfriendintroductions relationships-messaging
|
||||
GET api/relationships/v1/addfriendwithcode relationships-messaging
|
||||
POST api/relationships/v1/bulkignoreplatformusers relationships-messaging
|
||||
GET api/relationships/v1/favorite relationships-messaging
|
||||
POST api/relationships/v1/ignore moderation-safety
|
||||
POST api/relationships/v1/mute moderation-safety
|
||||
GET api/relationships/v1/unfavorite relationships-messaging
|
||||
POST api/relationships/v1/unignore moderation-safety
|
||||
POST api/relationships/v1/unmute moderation-safety
|
||||
GET api/relationships/v2/acceptfriendrequest relationships-messaging
|
||||
GET api/relationships/v2/addfriend relationships-messaging
|
||||
GET api/relationships/v2/get relationships-messaging
|
||||
GET api/relationships/v2/removefriend relationships-messaging
|
||||
GET api/relationships/v2/sendfriendrequest relationships-messaging
|
||||
POST api/roomCurrencies/v2/purchase room-economy
|
||||
POST api/roomEarningsDistributions/v1/earningsDistribution progression-misc
|
||||
PUT api/roomconsumables/v1/roomConsumable room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/room/{roomId} room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me room-economy
|
||||
DELETE api/roomconsumables/v1/roomConsumable/{id} room-economy
|
||||
PUT api/roomconsumables/v1/roomConsumable/{id}/consume room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/{id}/isOwned room-economy
|
||||
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency room-economy
|
||||
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens room-economy
|
||||
POST api/roomcurrencies/v1/awardCurrency/bulk room-economy
|
||||
POST api/roomcurrencies/v1/createCurrency room-economy
|
||||
POST api/roomcurrencies/v1/createPurchaseOffer room-economy
|
||||
GET api/roomcurrencies/v1/currencies room-economy
|
||||
POST api/roomcurrencies/v1/deletePurchaseOffer room-economy
|
||||
GET api/roomcurrencies/v1/getAllBalances room-economy
|
||||
GET api/roomcurrencies/v1/getBalance room-economy
|
||||
GET api/roomcurrencies/v1/getPurchaseOffersBatch room-economy
|
||||
POST api/roomcurrencies/v1/updateCurrency room-economy
|
||||
POST api/roomcurrencies/v1/updatePurchaseOffer room-economy
|
||||
POST api/roomkeys/v1/create room-economy
|
||||
DELETE api/roomkeys/v1/delete/{roomKeyId} room-economy
|
||||
GET api/roomkeys/v1/mine room-economy
|
||||
GET api/roomkeys/v1/owns room-economy
|
||||
POST api/roomkeys/v1/owns/bulk room-economy
|
||||
GET api/roomkeys/v1/purchased/{roomKeyId} room-economy
|
||||
GET api/roomkeys/v1/room?roomId={id} room-economy
|
||||
GET api/rooms/v1/filters rooms
|
||||
POST api/rooms/v1/verifyRole rooms
|
||||
POST api/rooms/v2/report rooms
|
||||
GET api/royale/v1/current progression-misc
|
||||
POST api/royale/v2/matchcomplete progression-misc
|
||||
POST api/sanitize/v1 identity-account
|
||||
POST api/sanitize/v1/isPure identity-account
|
||||
POST api/screensharereports/v1/report moderation-safety
|
||||
POST api/storefronts/v1/PurchaseRoomKeyWithCurrency commerce-subscription
|
||||
GET api/storefronts/v1/adcarouselitems commerce-subscription
|
||||
GET api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int} commerce-subscription
|
||||
POST api/storefronts/v1/buyForFreeGiftButton commerce-subscription
|
||||
POST api/storefronts/v1/buyProgressionEventXpBoost commerce-subscription
|
||||
POST api/storefronts/v1/buyPurchaseReminder commerce-subscription
|
||||
GET api/storefronts/v1/buyRoomKey commerce-subscription
|
||||
POST api/storefronts/v1/objectives commerce-subscription
|
||||
GET api/storefronts/v1/season/{storefrontType:int} commerce-subscription
|
||||
GET api/storefronts/v1/toptoday commerce-subscription
|
||||
POST api/storefronts/v1/trialInvention commerce-subscription
|
||||
GET api/storefronts/v1/trialInvention/duration commerce-subscription
|
||||
POST api/storefronts/v2/balance commerce-subscription
|
||||
POST api/storefronts/v2/buyElite commerce-subscription
|
||||
GET api/storefronts/v2/buyInvention commerce-subscription
|
||||
POST api/storefronts/v2/buyItem commerce-subscription
|
||||
POST api/storefronts/v2/buyTier commerce-subscription
|
||||
GET api/storefronts/v3/giftdropstore/{storefrontType:int} commerce-subscription
|
||||
GET api/storefronts/v4/balance/{currencyType:int} commerce-subscription
|
||||
GET api/subscriptionseasons/v1/seasons/current progression-misc
|
||||
GET api/testcasemanagement/v1/testcase/{id} config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/claim config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/comment config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/status config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/unclaim config-telemetry
|
||||
GET api/testcasemanagement/v1/testpass/{id} config-telemetry
|
||||
GET api/testcasemanagement/v1/testpasssummary config-telemetry
|
||||
POST api/ugcPurchasables/v1/items/bulk avatar-inventory
|
||||
GET api/ugcPurchasables/v1/items/room/{roomId} avatar-inventory
|
||||
GET api/versioncheck/v4 identity-account
|
||||
DELETE cachedlogin/current identity-account
|
||||
GET cachedlogin/forplatformid/{0}/{1} identity-account
|
||||
POST cachedlogin/forplatformids identity-account
|
||||
POST cachedlogin/migrate identity-account
|
||||
GET club/account/{0}/created social-clubs-chat
|
||||
GET club/categoryTags social-clubs-chat
|
||||
POST club/create social-clubs-chat
|
||||
DELETE club/home/me social-clubs-chat
|
||||
GET club/home/me social-clubs-chat
|
||||
PUT club/home/me social-clubs-chat
|
||||
GET club/mine/created social-clubs-chat
|
||||
GET club/mine/member social-clubs-chat
|
||||
GET club/mostactivetoday social-clubs-chat
|
||||
GET club/search social-clubs-chat
|
||||
DELETE club/{0} social-clubs-chat
|
||||
GET club/{0} social-clubs-chat
|
||||
DELETE club/{0}/additionalimage/{1} social-clubs-chat
|
||||
PUT club/{0}/additionalimage/{1} social-clubs-chat
|
||||
PUT club/{0}/clubChatEnabled social-clubs-chat
|
||||
DELETE club/{0}/clubhouse social-clubs-chat
|
||||
PUT club/{0}/clubhouse social-clubs-chat
|
||||
GET club/{0}/details social-clubs-chat
|
||||
GET club/{0}/hasDisabledClubChat social-clubs-chat
|
||||
DELETE club/{0}/mainimage social-clubs-chat
|
||||
PUT club/{0}/mainimage social-clubs-chat
|
||||
GET club/{0}/members social-clubs-chat
|
||||
POST club/{0}/members/acceptinvite social-clubs-chat
|
||||
POST club/{0}/members/acceptrequest social-clubs-chat
|
||||
POST club/{0}/members/acceptrequests social-clubs-chat
|
||||
POST club/{0}/members/ban social-clubs-chat
|
||||
GET club/{0}/members/banned social-clubs-chat
|
||||
PUT club/{0}/members/changetype social-clubs-chat
|
||||
POST club/{0}/members/declineinvite social-clubs-chat
|
||||
POST club/{0}/members/denyrequest social-clubs-chat
|
||||
POST club/{0}/members/denyrequests social-clubs-chat
|
||||
POST club/{0}/members/directJoin social-clubs-chat
|
||||
PUT club/{0}/members/invite social-clubs-chat
|
||||
POST club/{0}/members/invitemembers social-clubs-chat
|
||||
POST club/{0}/members/leave social-clubs-chat
|
||||
POST club/{0}/members/remove social-clubs-chat
|
||||
GET club/{0}/members/requests social-clubs-chat
|
||||
GET club/{0}/members/requests/search social-clubs-chat
|
||||
PUT club/{0}/members/requesttojoin social-clubs-chat
|
||||
GET club/{0}/members/search social-clubs-chat
|
||||
POST club/{0}/members/unban social-clubs-chat
|
||||
GET club/{0}/members/{1} social-clubs-chat
|
||||
PUT club/{0}/minlevel social-clubs-chat
|
||||
PUT club/{0}/modify social-clubs-chat
|
||||
PUT club/{0}/modifydetails social-clubs-chat
|
||||
PUT club/{0}/permissions/{1} social-clubs-chat
|
||||
GET clubhousesearch/mostactivenow social-clubs-chat
|
||||
POST comments/create/{0} social-clubs-chat
|
||||
DELETE comments/delete/{0} social-clubs-chat
|
||||
GET comments/get/{0} social-clubs-chat
|
||||
PUT comments/read/{0}/{1} social-clubs-chat
|
||||
GET comments/unreadcounts social-clubs-chat
|
||||
GET config/categories config-telemetry
|
||||
POST connect/deviceauthorization identity-account
|
||||
POST connect/token identity-account
|
||||
POST data/event events-playlists
|
||||
PUT datalink config-telemetry
|
||||
GET datalink/{code} config-telemetry
|
||||
GET eac/challenge identity-account
|
||||
GET econ/customAvatarItems/v1/itemOwnershipLimit avatar-inventory
|
||||
GET econ/customAvatarItems/v1/owned avatar-inventory
|
||||
POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} avatar-inventory
|
||||
GET emojiConfig/whitelistedEmojis config-telemetry
|
||||
GET event/{0}/instances events-playlists
|
||||
GET featuredrooms/current rooms
|
||||
POST feed/query config-telemetry
|
||||
DELETE invite/{0} progression-misc
|
||||
POST leaderboard/CheckAndSetStat progression-misc
|
||||
POST leaderboard/GetNearbyScores progression-misc
|
||||
POST leaderboard/GetPlayerRank progression-misc
|
||||
POST leaderboard/GetRanks progression-misc
|
||||
POST matchmake/chatinvite/{0}/{1} matchmaking-session
|
||||
POST matchmake/club/{0} matchmaking-session
|
||||
POST matchmake/code/{0}/{1} matchmaking-session
|
||||
POST matchmake/dorm matchmaking-session
|
||||
POST matchmake/event/{0} matchmaking-session
|
||||
POST matchmake/instance/{0} matchmaking-session
|
||||
POST matchmake/invite/{0} matchmaking-session
|
||||
POST matchmake/none matchmaking-session
|
||||
POST matchmake/player/{0} matchmaking-session
|
||||
POST matchmake/room/{0} matchmaking-session
|
||||
POST matchmake/room/{0}/{1} matchmaking-session
|
||||
GET members/bulk social-clubs-chat
|
||||
GET namegen/options identity-account
|
||||
POST pageview/consume config-telemetry
|
||||
GET parentalcontrol/me identity-account
|
||||
GET platformid/{0} identity-account
|
||||
GET player/avoidjuniors matchmaking-session
|
||||
POST player/exclusivelogin matchmaking-session
|
||||
POST player/heartbeat matchmaking-session
|
||||
POST player/login matchmaking-session
|
||||
POST player/logout matchmaking-session
|
||||
POST player/notifydisconnect matchmaking-session
|
||||
PUT player/photonregionpings matchmaking-session
|
||||
PUT player/statusvisibility matchmaking-session
|
||||
PUT player/vrmovementmode matchmaking-session
|
||||
POST playlists/bulk events-playlists
|
||||
GET playlists/cheeredby/me events-playlists
|
||||
GET playlists/createdby/me events-playlists
|
||||
GET playlists/favoritedby/me events-playlists
|
||||
GET playlists/visitedby/me events-playlists
|
||||
DELETE playlists/{0} events-playlists
|
||||
GET playlists/{0} events-playlists
|
||||
PUT playlists/{0}/accessibility events-playlists
|
||||
PUT playlists/{0}/description events-playlists
|
||||
PUT playlists/{0}/image events-playlists
|
||||
GET playlists/{0}/interactionby/me events-playlists
|
||||
PUT playlists/{0}/interactionby/me/cheer events-playlists
|
||||
PUT playlists/{0}/interactionby/me/favorite events-playlists
|
||||
PUT playlists/{0}/levelvoting events-playlists
|
||||
PUT playlists/{0}/name events-playlists
|
||||
PUT playlists/{0}/restrictions events-playlists
|
||||
PUT playlists/{0}/rooms/{1} events-playlists
|
||||
PUT playlists/{0}/tags events-playlists
|
||||
PUT playlists/{0}/warning events-playlists
|
||||
POST purchase/v1/cancelpurchase commerce-subscription
|
||||
POST purchase/v1/cleanuppending commerce-subscription
|
||||
POST purchase/v1/completepurchase commerce-subscription
|
||||
GET purchase/v1/hasspentmoney commerce-subscription
|
||||
POST purchase/v1/initiatepurchase commerce-subscription
|
||||
POST purchase/v1/processpurchase commerce-subscription
|
||||
GET purchasecampaign/allcurrent/v2 commerce-subscription
|
||||
POST purchasecampaign/shown commerce-subscription
|
||||
GET reminder/currentTokenBundles/v2 commerce-subscription
|
||||
POST remote-run/push-to-studio inventions-media
|
||||
GET role/developer/{0} progression-misc
|
||||
GET role/moderator/{0} progression-misc
|
||||
GET room/{0}/instances rooms
|
||||
PUT roominstance/{0}/inprogress rooms
|
||||
PUT roominstance/{0}/markprivate rooms
|
||||
POST roominstance/{0}/reportjoinresult rooms
|
||||
PUT roominstance/{0}/roomCode rooms
|
||||
GET rooms/base rooms
|
||||
GET rooms/bulk rooms
|
||||
GET rooms/cheeredby/me rooms
|
||||
GET rooms/contestwinners rooms
|
||||
GET rooms/createdby/me rooms
|
||||
GET rooms/createdby/{0} rooms
|
||||
GET rooms/curated_playlists rooms
|
||||
GET rooms/favoritedby/me rooms
|
||||
GET rooms/fromcreators rooms
|
||||
GET rooms/hot rooms
|
||||
GET rooms/magic_door rooms
|
||||
GET rooms/moderatedby/me rooms
|
||||
GET rooms/ownedby/me rooms
|
||||
GET rooms/ownedby/{0} rooms
|
||||
GET rooms/recommendations rooms
|
||||
GET rooms/rro_ids rooms
|
||||
GET rooms/search rooms
|
||||
GET rooms/topcreators rooms
|
||||
GET rooms/visitedby/me rooms
|
||||
GET rooms/visitedby/{0} rooms
|
||||
DELETE rooms/{0} rooms
|
||||
GET rooms/{0} rooms
|
||||
PUT rooms/{0}/accessibility rooms
|
||||
PUT rooms/{0}/allow_new_users rooms
|
||||
PUT rooms/{0}/automute rooms
|
||||
GET rooms/{0}/bans rooms
|
||||
POST rooms/{0}/bans rooms
|
||||
POST rooms/{0}/bans/import rooms
|
||||
DELETE rooms/{0}/bans/{1} rooms
|
||||
POST rooms/{0}/clone rooms
|
||||
PUT rooms/{0}/cloning rooms
|
||||
PUT rooms/{0}/comments rooms
|
||||
PUT rooms/{0}/creator rooms
|
||||
PUT rooms/{0}/description rooms
|
||||
PUT rooms/{0}/image rooms
|
||||
GET rooms/{0}/interactionby/me rooms
|
||||
DELETE rooms/{0}/interactionby/me/cheer rooms
|
||||
PUT rooms/{0}/interactionby/me/cheer rooms
|
||||
DELETE rooms/{0}/interactionby/me/favorite rooms
|
||||
PUT rooms/{0}/interactionby/me/favorite rooms
|
||||
DELETE rooms/{0}/loadscreen rooms
|
||||
PUT rooms/{0}/loadscreen rooms
|
||||
PUT rooms/{0}/max_player_calculation_mode rooms
|
||||
PUT rooms/{0}/min_level rooms
|
||||
PUT rooms/{0}/modify rooms
|
||||
PUT rooms/{0}/name rooms
|
||||
GET rooms/{0}/playerdata/me rooms
|
||||
PUT rooms/{0}/playerdata/me rooms
|
||||
DELETE rooms/{0}/promo_external rooms
|
||||
PUT rooms/{0}/promo_external rooms
|
||||
DELETE rooms/{0}/promo_images rooms
|
||||
PUT rooms/{0}/promo_images rooms
|
||||
PUT rooms/{0}/restrictions rooms
|
||||
GET rooms/{0}/roles rooms
|
||||
GET rooms/{0}/roles/{1} rooms
|
||||
PUT rooms/{0}/roles/{1} rooms
|
||||
PUT rooms/{0}/roles/{1}/invite rooms
|
||||
GET rooms/{0}/similar rooms
|
||||
POST rooms/{0}/subrooms rooms
|
||||
DELETE rooms/{0}/subrooms/{1} rooms
|
||||
PUT rooms/{0}/subrooms/{1}/accessibility rooms
|
||||
POST rooms/{0}/subrooms/{1}/clone rooms
|
||||
POST rooms/{0}/subrooms/{1}/data rooms
|
||||
PUT rooms/{0}/subrooms/{1}/maxplayers rooms
|
||||
PUT rooms/{0}/subrooms/{1}/modify rooms
|
||||
POST rooms/{0}/subrooms/{1}/move rooms
|
||||
PUT rooms/{0}/subrooms/{1}/name rooms
|
||||
PUT rooms/{0}/subrooms/{1}/permissions rooms
|
||||
POST rooms/{0}/subrooms/{1}/publish_save rooms
|
||||
GET rooms/{0}/subrooms/{1}/saves rooms
|
||||
PUT rooms/{0}/tags rooms
|
||||
PUT rooms/{0}/voice_chat_encryption rooms
|
||||
PUT rooms/{0}/warning rooms
|
||||
GET settings/partyinvite config-telemetry
|
||||
GET showcase/{0} inventions-media
|
||||
GET subscription/details/{accountId:int} commerce-subscription
|
||||
GET subscription/mine/member commerce-subscription
|
||||
GET subscription/subscriberCount/{accountId:int} commerce-subscription
|
||||
GET subscription/top/creators/today commerce-subscription
|
||||
POST subscription/v1/cancel commerce-subscription
|
||||
POST subscription/{accountId:int} commerce-subscription
|
||||
GET thread/club/{0} social-clubs-chat
|
||||
PUT thread/message/{0}/moderate social-clubs-chat
|
||||
POST thread/withmembers social-clubs-chat
|
||||
GET thread/{0} social-clubs-chat
|
||||
POST thread/{0} social-clubs-chat
|
||||
PUT thread/{0}/favorite social-clubs-chat
|
||||
POST thread/{0}/leave social-clubs-chat
|
||||
POST thread/{0}/member/{1} social-clubs-chat
|
||||
GET thread/{0}/message social-clubs-chat
|
||||
POST thread/{0}/message/{1}/read social-clubs-chat
|
||||
POST thread/{0}/rename social-clubs-chat
|
||||
POST thread/{0}/snooze social-clubs-chat
|
||||
GET unity_assets/{0}/{1}/{2} inventions-media
|
||||
GET voice/config moderation-safety
|
||||
GET voice/requiresModeration moderation-safety
|
||||
|
@@ -1,142 +0,0 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace DorkNet.Server.Binding;
|
||||
|
||||
/// <summary>
|
||||
/// Binds an action's request DTO from either a form-urlencoded body or a JSON
|
||||
/// body, whichever the caller actually sent.
|
||||
///
|
||||
/// The Rec Room client's HTTP layer (<c>BNDIAONDFFF</c> in RecNet.Runtime)
|
||||
/// sends nearly every write as <c>application/x-www-form-urlencoded</c>, adding
|
||||
/// fields one at a time. Handlers written against <c>[FromBody]</c> only accept
|
||||
/// JSON, so with <c>[ApiController]</c> those requests were rejected with 415
|
||||
/// Unsupported Media Type before the action body ever ran — this is why every
|
||||
/// club member-management action (invite, ban, kick, promote, approve, modify)
|
||||
/// failed on the 2023 client.
|
||||
///
|
||||
/// Rather than hand-rolling a form reader per handler, apply this binder to the
|
||||
/// DTO parameter:
|
||||
///
|
||||
/// <code>
|
||||
/// public Task<IActionResult> MemberBan(
|
||||
/// long clubId,
|
||||
/// [ModelBinder(typeof(FormOrJsonModelBinder))] MemberTargetRequest req)
|
||||
/// </code>
|
||||
///
|
||||
/// The explicit <c>[ModelBinder]</c> is required: without it <c>[ApiController]</c>
|
||||
/// infers <c>[FromBody]</c> for complex types and this binder never runs.
|
||||
///
|
||||
/// Matching is case-insensitive on both paths, and a scalar property also
|
||||
/// accepts the singular/plural spellings the client uses interchangeably. A
|
||||
/// missing or unparseable body yields a default-constructed DTO rather than a
|
||||
/// 400, matching how the client treats these calls as fire-and-forget.
|
||||
/// </summary>
|
||||
public sealed class FormOrJsonModelBinder : IModelBinder
|
||||
{
|
||||
public async Task BindModelAsync(ModelBindingContext bindingContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindingContext);
|
||||
|
||||
var type = bindingContext.ModelType;
|
||||
var request = bindingContext.HttpContext.Request;
|
||||
var model = Activator.CreateInstance(type)!;
|
||||
|
||||
if (request.HasFormContentType)
|
||||
{
|
||||
var form = await request.ReadFormAsync(bindingContext.HttpContext.RequestAborted);
|
||||
foreach (var prop in Writable(type))
|
||||
{
|
||||
var values = Lookup(form, prop.Name);
|
||||
if (values.Count == 0) continue;
|
||||
var converted = Convert(values, prop.PropertyType);
|
||||
if (converted is not null) prop.SetValue(model, converted);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
request.EnableBuffering();
|
||||
request.Body.Position = 0;
|
||||
var parsed = await JsonSerializer.DeserializeAsync(
|
||||
request.Body, type,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
|
||||
bindingContext.HttpContext.RequestAborted);
|
||||
request.Body.Position = 0;
|
||||
if (parsed is not null) model = parsed;
|
||||
}
|
||||
catch (JsonException) { /* empty or non-JSON body → defaults */ }
|
||||
catch (NotSupportedException) { /* unreadable body → defaults */ }
|
||||
}
|
||||
|
||||
bindingContext.Result = ModelBindingResult.Success(model);
|
||||
}
|
||||
|
||||
private static IEnumerable<PropertyInfo> Writable(Type type) =>
|
||||
type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanWrite);
|
||||
|
||||
/// <summary>Form keys, case-insensitively, also accepting the other
|
||||
/// number of the name — the client says <c>accountId</c> where the DTO says
|
||||
/// <c>AccountIds</c> and vice versa depending on the endpoint.</summary>
|
||||
private static List<string> Lookup(IFormCollection form, string propertyName)
|
||||
{
|
||||
var candidates = new List<string> { propertyName };
|
||||
if (propertyName.EndsWith('s')) candidates.Add(propertyName[..^1]);
|
||||
else candidates.Add(propertyName + "s");
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
foreach (var key in form.Keys)
|
||||
{
|
||||
if (!string.Equals(key, candidate, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var values = form[key]
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v!)
|
||||
.ToList();
|
||||
if (values.Count > 0) return values;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private static object? Convert(List<string> values, Type target)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(target) ?? target;
|
||||
|
||||
if (underlying.IsGenericType && underlying.GetGenericTypeDefinition() == typeof(List<>))
|
||||
{
|
||||
var element = underlying.GetGenericArguments()[0];
|
||||
var list = (System.Collections.IList)Activator.CreateInstance(underlying)!;
|
||||
// A repeated field arrives as multiple values; a single field may
|
||||
// still carry a comma-separated list.
|
||||
foreach (var raw in values.SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)))
|
||||
{
|
||||
var item = ConvertScalar(raw, element);
|
||||
if (item is not null) list.Add(item);
|
||||
}
|
||||
return list.Count > 0 ? list : null;
|
||||
}
|
||||
|
||||
return ConvertScalar(values[0], underlying);
|
||||
}
|
||||
|
||||
private static object? ConvertScalar(string raw, Type target)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (target == typeof(string)) return raw;
|
||||
if (target == typeof(bool)) return bool.TryParse(raw, out var b) ? b : raw == "1";
|
||||
if (target.IsEnum) return Enum.TryParse(target, raw, ignoreCase: true, out var e) ? e : null;
|
||||
if (target == typeof(Guid)) return Guid.TryParse(raw, out var g) ? g : null;
|
||||
if (target == typeof(DateTime)) return DateTime.TryParse(raw, out var d) ? d : null;
|
||||
return System.Convert.ChangeType(raw, target, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace DorkNet.Server.Compat;
|
||||
|
||||
/// <summary>Identifies a specific Rec Room client build.
|
||||
/// <para>
|
||||
/// <see cref="Key"/> is the wire-format identifier the client mod
|
||||
/// stamps on every request via the <c>X-DorkNet-Version</c> header.
|
||||
/// Format is <c>{branch}_{semver}</c>: <c>march_2020_03_10</c>,
|
||||
/// <c>december_2020_12_18</c>. Keep it lowercase, underscore-separated;
|
||||
/// it shows up in admin logs and error responses.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Generation"/> is the wire-format-compatibility group:
|
||||
/// every version inside the same generation shares the same wire
|
||||
/// shapes (JSON DTO casing, route names, etc.) and therefore the same
|
||||
/// strategy implementations. Adding a new build that's protocol-compatible
|
||||
/// with an existing generation = a one-line registration; only a new
|
||||
/// generation entails new strategy code.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ClientVersion(string Key, string Generation)
|
||||
{
|
||||
/// <summary>Sentinel returned when a request has no
|
||||
/// <c>X-DorkNet-Version</c> header AND no
|
||||
/// <c>DorkNet:DefaultClientVersion</c> is configured. Controllers
|
||||
/// can branch on this to either fall through to a sensible default
|
||||
/// or 426 the request.</summary>
|
||||
public static readonly ClientVersion Unknown = new("unknown", "unknown");
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DorkNet.Server.Compat;
|
||||
|
||||
/// <summary>Helpers to read the active <see cref="ClientVersion"/>
|
||||
/// off the current request. The version is set by
|
||||
/// <c>VersionDetectionMiddleware</c> early in the pipeline so any
|
||||
/// downstream consumer (controllers, services, filters) can resolve
|
||||
/// it without re-parsing the header.</summary>
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
private const string ClientVersionKey = "DorkNet.ClientVersion";
|
||||
|
||||
/// <summary>Stamp the resolved version on the request. Called by
|
||||
/// <c>VersionDetectionMiddleware</c>; not for general use.</summary>
|
||||
internal static void SetClientVersion(this HttpContext ctx, ClientVersion v)
|
||||
=> ctx.Items[ClientVersionKey] = v;
|
||||
|
||||
/// <summary>The client version for this request. Returns
|
||||
/// <see cref="ClientVersion.Unknown"/> if the middleware hasn't
|
||||
/// run (e.g. a unit test that calls a controller method directly)
|
||||
/// rather than null, so callers can safely branch on
|
||||
/// <c>Generation</c> without null-checks.</summary>
|
||||
public static ClientVersion ClientVersion(this HttpContext ctx)
|
||||
=> ctx.Items.TryGetValue(ClientVersionKey, out var v)
|
||||
&& v is ClientVersion cv
|
||||
? cv
|
||||
: Compat.ClientVersion.Unknown;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
namespace DorkNet.Server.Compat;
|
||||
|
||||
/// <summary>One client-version-family plugin. Each implementation
|
||||
/// declares which exact version <see cref="VersionKeys"/> it handles
|
||||
/// and which protocol-compatibility <see cref="Generation"/> they map
|
||||
/// to. The plugin's job at registration time is to also wire any
|
||||
/// generation-specific strategy services into DI via
|
||||
/// <see cref="RegisterStrategies"/> so controllers / services
|
||||
/// downstream can resolve the right <c>IWireFormat</c>,
|
||||
/// <c>IRouteOverride</c>, etc. for the active request's generation.
|
||||
///
|
||||
/// <para>Adding a new version with the SAME wire shapes as an existing
|
||||
/// generation = just listing its key in that generation's plugin
|
||||
/// <see cref="VersionKeys"/>. Only a new generation needs new
|
||||
/// strategy classes.</para>
|
||||
/// </summary>
|
||||
public interface IVersionPlugin
|
||||
{
|
||||
/// <summary>All client-build keys this plugin handles. Multiple
|
||||
/// keys can share one plugin when several Rec Room builds shipped
|
||||
/// the same wire format. Convention: lowercase, underscore-separated,
|
||||
/// matching the literal the client mod sends in
|
||||
/// <c>X-DorkNet-Version</c>.</summary>
|
||||
IReadOnlyCollection<string> VersionKeys { get; }
|
||||
|
||||
/// <summary>Wire-format-compatibility group this plugin implements.
|
||||
/// Multiple plugins can share a generation (rare, but allowed when
|
||||
/// e.g. a Quest build and a PC build of the same era are
|
||||
/// protocol-identical and we just want separate
|
||||
/// <see cref="VersionKeys"/> bookkeeping).</summary>
|
||||
string Generation { get; }
|
||||
|
||||
/// <summary>Register any generation-specific strategy services
|
||||
/// into DI. Called once at startup by the bootstrap code in
|
||||
/// <c>Program.cs</c>. Use keyed registrations (keyed by
|
||||
/// <see cref="Generation"/>) so requests for other generations
|
||||
/// don't collide.</summary>
|
||||
void RegisterStrategies(IServiceCollection services);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Compat;
|
||||
|
||||
/// <summary>Extracts the client version from <c>X-DorkNet-Version</c>
|
||||
/// on every request, validates it against this deployment's allow-list,
|
||||
/// and either:
|
||||
/// <list type="bullet">
|
||||
/// <item>Stamps <see cref="HttpContextExtensions.ClientVersion"/> with
|
||||
/// the resolved <see cref="ClientVersion"/> and forwards the request, OR</item>
|
||||
/// <item>Returns <c>HTTP 426 Upgrade Required</c> with a JSON body
|
||||
/// describing the mismatch when the client's version isn't in
|
||||
/// <see cref="VersionRegistry.SupportedVersionKeys"/>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Headerless requests fall back to
|
||||
/// <see cref="VersionRegistry.DefaultVersionKey"/> when set. That's
|
||||
/// the transition pathway — once every shipped client mod stamps the
|
||||
/// header, set <c>DorkNet:DefaultClientVersion</c> to empty and
|
||||
/// headerless requests get rejected too.</para>
|
||||
///
|
||||
/// <para>Some routes intentionally don't carry a client identity
|
||||
/// (the public-facing site at the apex, the admin SPA at
|
||||
/// <c>admin.{apex}</c>, health checks). Those are excluded by host
|
||||
/// + path prefix below — they always skip the version gate and
|
||||
/// receive <see cref="ClientVersion.Unknown"/>. The apex is whatever
|
||||
/// <see cref="DomainConfig.Apex"/> resolved to at startup, so forks
|
||||
/// running on a different domain get the same exemption automatically.</para>
|
||||
/// </summary>
|
||||
public sealed class VersionDetectionMiddleware(
|
||||
RequestDelegate next,
|
||||
VersionRegistry registry,
|
||||
DomainConfig domain,
|
||||
ILogger<VersionDetectionMiddleware> logger)
|
||||
{
|
||||
private const string HeaderName = "X-DorkNet-Version";
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx)
|
||||
{
|
||||
if (IsExempt(ctx))
|
||||
{
|
||||
ctx.SetClientVersion(ClientVersion.Unknown);
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
var headerVal = ctx.Request.Headers[HeaderName].ToString();
|
||||
var versionKey = string.IsNullOrWhiteSpace(headerVal)
|
||||
? registry.DefaultVersionKey
|
||||
: headerVal.Trim();
|
||||
|
||||
// No header AND no default configured — reject. Once every
|
||||
// client mod sends the header this is the path that catches
|
||||
// probes / misconfigured clients.
|
||||
if (string.IsNullOrWhiteSpace(versionKey))
|
||||
{
|
||||
await WriteMismatch(ctx, "client_version_missing", got: null);
|
||||
return;
|
||||
}
|
||||
|
||||
var resolved = registry.Resolve(versionKey);
|
||||
if (resolved == ClientVersion.Unknown)
|
||||
{
|
||||
// The client claims a version but no plugin knows it. That
|
||||
// means either a typo in the header or a build of Rec Room
|
||||
// we haven't onboarded yet. Either way, reject — server
|
||||
// can't reliably serve a version it doesn't have a
|
||||
// strategy bundle for.
|
||||
await WriteMismatch(ctx, "client_version_unknown", got: versionKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!registry.IsSupported(versionKey))
|
||||
{
|
||||
// Known version, but this specific deployment is
|
||||
// configured to accept a different set. e.g. a March
|
||||
// client hitting a December-only server.
|
||||
await WriteMismatch(ctx, "client_version_not_accepted", got: versionKey);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.SetClientVersion(resolved);
|
||||
await next(ctx);
|
||||
}
|
||||
|
||||
private bool IsExempt(HttpContext ctx)
|
||||
{
|
||||
// Hosts that don't serve the watch — these are humans on a
|
||||
// browser or admin tooling, not the game client.
|
||||
var host = ctx.Request.Host.Host.ToLowerInvariant();
|
||||
var apex = domain.Apex.ToLowerInvariant();
|
||||
if (host == apex || host == "www." + apex) return true;
|
||||
if (host.StartsWith("admin.")) return true;
|
||||
if (host.StartsWith("site.")) return true;
|
||||
if (host.StartsWith("feed.")) return true;
|
||||
|
||||
// Server health / readiness probes.
|
||||
var path = ctx.Request.Path.Value;
|
||||
if (path is null) return false;
|
||||
if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (path.StartsWith("/api/admin/", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
// The boot-time version gate itself — the whole POINT of this
|
||||
// endpoint is to receive a mismatched-version client and reply
|
||||
// with the watch's native "Update Required" payload. Gating it
|
||||
// here would 426 the request before VersionCheckController
|
||||
// could respond.
|
||||
if (path.StartsWith("/api/versioncheck/", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task WriteMismatch(HttpContext ctx, string error, string? got)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[version-gate] rejecting request path={Path} host={Host} reason={Reason} got={Got} supported=[{Supported}]",
|
||||
ctx.Request.Path, ctx.Request.Host.Host, error, got ?? "(none)",
|
||||
string.Join(",", registry.SupportedVersionKeys));
|
||||
|
||||
ctx.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
|
||||
ctx.Response.ContentType = "application/json; charset=utf-8";
|
||||
var body = new
|
||||
{
|
||||
error,
|
||||
got,
|
||||
supported = registry.SupportedVersionKeys.ToArray(),
|
||||
message = error switch
|
||||
{
|
||||
"client_version_missing" =>
|
||||
"Client did not send an X-DorkNet-Version header. Update your DorkNet client mod.",
|
||||
"client_version_unknown" =>
|
||||
$"Client claims version '{got}', which no version plugin in this server recognises.",
|
||||
"client_version_not_accepted" =>
|
||||
$"Client version '{got}' is known but this deployment is configured to accept only: " +
|
||||
string.Join(", ", registry.SupportedVersionKeys) + ".",
|
||||
_ => error,
|
||||
},
|
||||
};
|
||||
await JsonSerializer.SerializeAsync(ctx.Response.Body, body);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
namespace DorkNet.Server.Compat;
|
||||
|
||||
/// <summary>Resolves a client-build key (e.g. <c>december_2020_12_18</c>)
|
||||
/// to its <see cref="ClientVersion"/>. Built once at startup from all
|
||||
/// registered <see cref="IVersionPlugin"/> instances.
|
||||
///
|
||||
/// <para>Singleton — the version-key → generation mapping is fixed at
|
||||
/// boot time, and the registry is read on every request by the
|
||||
/// version-detection middleware.</para>
|
||||
/// </summary>
|
||||
public sealed class VersionRegistry
|
||||
{
|
||||
private readonly Dictionary<string, ClientVersion> _byKey;
|
||||
|
||||
/// <summary>The set of version keys this deployment is willing to
|
||||
/// serve. Comes from <c>DorkNet:SupportedVersions</c> in config.
|
||||
/// Anything else gets a 426 from the middleware.</summary>
|
||||
public IReadOnlySet<string> SupportedVersionKeys { get; }
|
||||
|
||||
/// <summary>Falls back to this when a request arrives without an
|
||||
/// <c>X-DorkNet-Version</c> header — set via
|
||||
/// <c>DorkNet:DefaultClientVersion</c> in config. Empty/null means
|
||||
/// "no fallback; reject headerless requests". Set during the
|
||||
/// transition window where the old client mod doesn't yet stamp
|
||||
/// the header.</summary>
|
||||
public string? DefaultVersionKey { get; }
|
||||
|
||||
public VersionRegistry(
|
||||
IEnumerable<IVersionPlugin> plugins,
|
||||
IReadOnlySet<string> supportedVersionKeys,
|
||||
string? defaultVersionKey)
|
||||
{
|
||||
_byKey = new Dictionary<string, ClientVersion>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
foreach (var key in plugin.VersionKeys)
|
||||
{
|
||||
if (_byKey.ContainsKey(key))
|
||||
throw new InvalidOperationException(
|
||||
$"VersionPlugin collision: '{key}' is claimed by more than one IVersionPlugin. " +
|
||||
$"Each version key maps to exactly one plugin.");
|
||||
_byKey[key] = new ClientVersion(key, plugin.Generation);
|
||||
}
|
||||
}
|
||||
SupportedVersionKeys = supportedVersionKeys;
|
||||
DefaultVersionKey = defaultVersionKey;
|
||||
}
|
||||
|
||||
/// <summary>Resolve a version key (case-insensitive) to its
|
||||
/// <see cref="ClientVersion"/>, or <see cref="ClientVersion.Unknown"/>
|
||||
/// if no plugin claims it. The middleware decides whether unknown
|
||||
/// → reject or → fall back to <see cref="DefaultVersionKey"/>.</summary>
|
||||
public ClientVersion Resolve(string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return ClientVersion.Unknown;
|
||||
return _byKey.TryGetValue(key, out var v) ? v : ClientVersion.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>True when the given version key is in
|
||||
/// <see cref="SupportedVersionKeys"/>. Used by the middleware to
|
||||
/// 426 cross-version connection attempts (e.g. a March client
|
||||
/// hitting a server configured for December only).</summary>
|
||||
public bool IsSupported(string key)
|
||||
=> SupportedVersionKeys.Contains(key);
|
||||
|
||||
/// <summary>Every known plugin-registered version key, for
|
||||
/// diagnostic responses and admin tooling.</summary>
|
||||
public IEnumerable<ClientVersion> AllKnown => _byKey.Values;
|
||||
}
|
||||
@@ -106,11 +106,9 @@ public class AccountApiController(
|
||||
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? auth["Bearer ".Length..].Trim()
|
||||
: Request.Cookies[AuthService.AccessCookieName];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
@@ -130,8 +128,6 @@ public class AccountApiController(
|
||||
var name = (displayName ?? value ?? string.Empty).Trim();
|
||||
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
|
||||
await playerService.UpdateDisplayNameAsync(p.Id, name);
|
||||
var updated = await playerService.GetByIdAsync(p.Id);
|
||||
if (updated is not null) await notifications.AccountChanged(updated);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
@@ -157,7 +153,6 @@ public class AccountApiController(
|
||||
if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" });
|
||||
p.Username = newName;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(p);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
@@ -203,12 +198,12 @@ public class AccountApiController(
|
||||
|
||||
[HttpPost("api/account/v1/profileimage")]
|
||||
[HttpPost("api/account/me/profileimage")]
|
||||
[HttpPost("account/me/profileimage", Order = 10)]
|
||||
[HttpPost("profileimage", Order = 10)]
|
||||
[HttpPost("account/me/profileimage")]
|
||||
[HttpPost("profileimage")]
|
||||
[HttpPut("api/account/v1/profileimage")]
|
||||
[HttpPut("api/account/me/profileimage")]
|
||||
[HttpPut("account/me/profileimage", Order = 10)]
|
||||
[HttpPut("profileimage", Order = 10)]
|
||||
[HttpPut("account/me/profileimage")]
|
||||
[HttpPut("profileimage")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SetProfileImage(
|
||||
[FromForm(Name = "imageName")] string? imageName,
|
||||
@@ -227,10 +222,44 @@ public class AccountApiController(
|
||||
"[api-account] profile image set: player={PlayerId} -> {Name}",
|
||||
player.Id,
|
||||
player.ProfileImageName ?? "<cleared>");
|
||||
await notifications.AccountChanged(player);
|
||||
await NotifyAccountImageChangedAsync(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
|
||||
{
|
||||
var account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = player.IsJunior,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
};
|
||||
var selfAccount = new RecNetSelfAccount
|
||||
{
|
||||
AccountId = account.AccountId,
|
||||
RawUsername = account.RawUsername,
|
||||
Username = account.Username,
|
||||
DisplayName = account.DisplayName,
|
||||
ProfileImage = account.ProfileImage,
|
||||
TreatAsJunior = account.TreatAsJunior,
|
||||
HasBirthday = account.HasBirthday,
|
||||
Platforms = account.Platforms,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = player.Phone ?? string.Empty,
|
||||
Birthday = player.Birthday,
|
||||
JuniorState = player.IsJunior ? 1 : 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
return Task.WhenAll(
|
||||
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
|
||||
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
|
||||
}
|
||||
|
||||
[HttpGet("api/account/v1/namegen")]
|
||||
[HttpGet("api/account/v1/namegeneration")]
|
||||
public IActionResult NameGen() => Ok(new
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Activities;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/activities/* — per-activity content
|
||||
/// (charades words, party-game prompts, etc.).
|
||||
///
|
||||
/// The March 2023 watch fetches a 3D Charades deck at card-box spawn from
|
||||
/// <c>api/activities/charades/v1/words/{source}</c>, where <c>{source}</c>
|
||||
/// is one of three baked <c>CardBox.cardSource</c> slots — <c>Charades</c>,
|
||||
/// <c>CharadesAprilFoolsDay</c>, or <c>Icebreakers</c> (verified in the
|
||||
/// 2023.03.21 il2cpp dump). We resolve the admin-bound word list for the
|
||||
/// requested slot via <see cref="CharadesWordListService"/> and return the
|
||||
/// wire array of <c>{ EN_US, Difficulty }</c>. The older December route
|
||||
/// (no <c>{source}</c>) maps to the default Charades slot.
|
||||
/// (charades words, party-game prompts, etc.). The 2020 watch
|
||||
/// fetches these as a static list at room-join time; we ship an
|
||||
/// empty list so the activity's bundled defaults kick in.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ActivitiesController(CharadesWordListService charades) : ControllerBase
|
||||
public class ActivitiesController : ControllerBase
|
||||
{
|
||||
[HttpGet("api/activities/charades/v1/words/{source}")]
|
||||
public async Task<IActionResult> CharadesWords(string source)
|
||||
=> Ok(await charades.ResolveWireWordsAsync(source));
|
||||
|
||||
/// <summary>December 2020 shape — no source segment. Serves the default
|
||||
/// Charades slot so the older client still gets a populated deck.</summary>
|
||||
[HttpGet("api/activities/charades/v1/words")]
|
||||
public async Task<IActionResult> CharadesWordsDefault()
|
||||
=> Ok(await charades.ResolveWireWordsAsync(
|
||||
nameof(CharadesWordListService.CharadesSlot.Charades)));
|
||||
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.AgeVerification;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ageverification")]
|
||||
[Authorize]
|
||||
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>The 2023-03-21 client POSTs this (verb register <c>rdx=2</c>
|
||||
/// at RecNet.Runtime/ALLHCFFLMLD.txt:216-222) and deserialises the body as
|
||||
/// a bare <c>String</c>: the issuing method is
|
||||
/// <c>FGLDKEJLAKB<IPCJLCNIBEG<System.String>> GKEBPOPAEJC()</c>
|
||||
/// (:150). Returning a JSON object here made Json.NET throw, so the
|
||||
/// promise rejected and the code was never shown in the watch.
|
||||
/// GET stays registered because it costs nothing.</summary>
|
||||
[HttpPost("generateCode")]
|
||||
[HttpGet("generateCode")]
|
||||
public async Task<IActionResult> GenerateCode()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"ageverification:{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
Value = code,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Content(JsonSerializer.Serialize(code), "application/json");
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.AppIntegrity;
|
||||
|
||||
[ApiController]
|
||||
public class AppIntegrityController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/AppIntegrity/v1/iosproducts")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> IosProducts()
|
||||
{
|
||||
var rows = await db.StoreItems
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.Id)
|
||||
.Take(100)
|
||||
.Select(i => new
|
||||
{
|
||||
ProductId = i.Slug,
|
||||
i.Price,
|
||||
i.CurrencyType,
|
||||
Name = i.DisplayName,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpPost("api/AppIntegrity/v1/iospaymentqueuefailed")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IosPaymentQueueFailed([FromForm] string? reason)
|
||||
{
|
||||
db.BugReports.Add(new BugReportEntity
|
||||
{
|
||||
ReporterPlayerId = this.RequireCurrentPlayerId(),
|
||||
Title = "iOS payment queue failed",
|
||||
Body = reason ?? Request.Query["reason"].FirstOrDefault() ?? string.Empty,
|
||||
Category = "appintegrity",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Apple;
|
||||
|
||||
[ApiController]
|
||||
public class AppleMusicPromotionController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/apple/musicpromotion/active")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Active()
|
||||
{
|
||||
var start = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var end = start.AddYears(1);
|
||||
return Ok(new { Active = true, StartAt = start, EndAt = end });
|
||||
}
|
||||
|
||||
[HttpPost("api/apple/musicpromotion/code")]
|
||||
[HttpGet("api/apple/musicpromotion/code")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Code()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "applemusic:promotion_code");
|
||||
if (row is null)
|
||||
{
|
||||
row = new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "applemusic:promotion_code",
|
||||
Value = $"RR-{Guid.NewGuid():N}"[..16],
|
||||
};
|
||||
db.PlayerSettings.Add(row);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new { Code = row.Value, Redeemed = false });
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,7 @@ namespace DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
public class AvatarGiftsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
LevelService level,
|
||||
StoreService store,
|
||||
QuestRewardService questRewards,
|
||||
PlayerPresenceService presence) : ControllerBase
|
||||
LevelService level) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/avatar/v2/gifts")]
|
||||
[HttpGet("api/avatar/v3/gifts")]
|
||||
@@ -50,46 +47,26 @@ public class AvatarGiftsController(
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message);
|
||||
public sealed record GenerateGiftRequest(int? GiftContext);
|
||||
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of-
|
||||
/// activity gift box (chest). For a game/quest gift (IsGameGift) the
|
||||
/// server first tries the quest's configured item pool, keyed by the
|
||||
/// RRO room the player is in (the request carries no quest id). If
|
||||
/// that room has no pool — or for non-game free-gift tiles — it falls
|
||||
/// back to a random wardrobe item. The rolled item is stamped as a
|
||||
/// gift package; opening the box (<c>gifts/consume</c>) grants it.</summary>
|
||||
// The 2023 client posts the gift-chest generate to v3 (GET gifts is
|
||||
// already fanned across v2/v3/v4, but generate only routed v2 — the v3
|
||||
// POST 404'd, breaking reward chests on that path). Same request body.
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — server picks
|
||||
/// a random unlocked-avatar item and queues a gift package. The
|
||||
/// 2020 client posts to this when the user clicks a free-gift
|
||||
/// tile or hits a context the client maps to <c>GiftContext</c>.</summary>
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
[HttpPost("api/avatar/v3/gifts/generate")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var isGameGift = req?.IsGameGift ?? false;
|
||||
var rng = new Random();
|
||||
|
||||
// Quest chest: pick from this room's configured reward pool.
|
||||
if (isGameGift && presence.GetRoom(pid) is { RoomId: > 0 } room
|
||||
&& await questRewards.PickForRoomAsync(room.RoomId, rng.Next()) is { } questItem)
|
||||
{
|
||||
var gift = BuildGiftFromItem(pid, questItem, req?.GiftContext ?? 0, rng);
|
||||
gift.SourceStoreItemId = questItem.Id;
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.NotifyAsync(pid, PushNotificationId.GiftPackageReceived, ToWire(gift));
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
// Fallback: random wardrobe StoreItem (Slug "wardrobe-{guid}").
|
||||
// Pick a random wardrobe StoreItem (Slug starts with
|
||||
// "wardrobe-" — that's the convention StoreService uses for
|
||||
// catalogue rows derived from outfits_assets bundles).
|
||||
var pool = await db.StoreItems
|
||||
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
|
||||
.Select(s => new { s.Slug, s.DisplayName })
|
||||
.ToListAsync();
|
||||
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
|
||||
|
||||
var rng = new Random();
|
||||
var pick = pool[rng.Next(pool.Count)];
|
||||
var guid = pick.Slug["wardrobe-".Length..];
|
||||
|
||||
@@ -97,7 +74,7 @@ public class AvatarGiftsController(
|
||||
// so the watch's parse-and-iterate doesn't blow up in
|
||||
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
|
||||
// for the canonical wire-format note.
|
||||
var randomGift = new GiftPackageEntity
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
FromPlayerId = null,
|
||||
@@ -113,57 +90,15 @@ public class AvatarGiftsController(
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(randomGift);
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push so the watch shows the new-gift toast.
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.GiftPackageReceived, ToWire(randomGift));
|
||||
return Ok(ToWire(randomGift));
|
||||
PushNotificationId.GiftPackageReceived, ToWire(gift));
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
/// <summary>Build a gift package for a specific store item, filling
|
||||
/// the wire payload that matches its kind (avatar item vs equipment
|
||||
/// vs consumable) so <c>gifts/consume</c> grants the right thing.</summary>
|
||||
private static GiftPackageEntity BuildGiftFromItem(long pid, GiftPackageItem item, int giftContext, Random rng)
|
||||
{
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
FromPlayerId = null,
|
||||
CurrencyType = 0,
|
||||
GiftContext = giftContext,
|
||||
GiftRarity = RollRarity(rng),
|
||||
Message = $"You earned {item.DisplayName}!",
|
||||
Platform = -1,
|
||||
PackageVariant = "Standard",
|
||||
PackageMaterial = string.Empty,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
|
||||
if (StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc))
|
||||
{
|
||||
gift.AvatarItemType = avatarItemType;
|
||||
gift.AvatarItemDescOrHairDyeDesc = avatarItemDesc;
|
||||
}
|
||||
else if (StoreService.TryGetEquipmentPayload(item.Slug, out var prefab, out var modGuid, out _))
|
||||
{
|
||||
gift.EquipmentPrefabName = prefab;
|
||||
gift.EquipmentModificationGuid = modGuid;
|
||||
}
|
||||
else if (StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc))
|
||||
{
|
||||
gift.ConsumableItemDesc = consumableDesc;
|
||||
}
|
||||
return gift;
|
||||
}
|
||||
|
||||
private static GiftPackageEntity BuildGiftFromItem(long pid, StoreItemEntity item, int giftContext, Random rng) =>
|
||||
BuildGiftFromItem(pid, new GiftPackageItem(item.Slug, item.DisplayName), giftContext, rng);
|
||||
|
||||
private readonly record struct GiftPackageItem(string Slug, string DisplayName);
|
||||
|
||||
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
|
||||
public Task<IActionResult> ConsumeViaPath(long id) => ConsumeImpl(id);
|
||||
|
||||
@@ -233,30 +168,11 @@ public class AvatarGiftsController(
|
||||
if (gift.RecipientPlayerId != pid) return Forbid();
|
||||
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
|
||||
|
||||
// Quest-reward chest: grant the exact backing store item (handles
|
||||
// avatar / equipment / consumable uniformly, idempotently). The
|
||||
// per-kind wire-derivation below is skipped for these — the wire
|
||||
// fields are still populated for the client to render the box.
|
||||
if (gift.SourceStoreItemId > 0)
|
||||
{
|
||||
await store.GrantItemAsync(pid, gift.SourceStoreItemId);
|
||||
gift.Consumed = true;
|
||||
gift.ConsumedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
// Append avatar item to player's inventory list (JSON list of
|
||||
// GUID strings). Defensive parse — corrupted JSON resets to
|
||||
// empty list rather than 500.
|
||||
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
|
||||
{
|
||||
// Inventory stores the BARE guid ("guid"), but a gift now carries
|
||||
// the 4-part wire desc ("guid,,,") so its post-consume unlock
|
||||
// matches api/avatar/v4/items. Normalise to the inventory form
|
||||
// here or we'd store "guid,,," alongside the bare "guid" a direct
|
||||
// purchase already added → a duplicate wardrobe row.
|
||||
var invDesc = StoreService.InventoryAvatarItemDesc(gift.AvatarItemDescOrHairDyeDesc);
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
{
|
||||
@@ -267,46 +183,21 @@ public class AvatarGiftsController(
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer
|
||||
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
if (!list.Contains(invDesc))
|
||||
if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc))
|
||||
{
|
||||
list.Add(invDesc);
|
||||
list.Add(gift.AvatarItemDescOrHairDyeDesc);
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { invDesc });
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new[] { gift.AvatarItemDescOrHairDyeDesc });
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gift.EquipmentPrefabName) ||
|
||||
!string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid))
|
||||
{
|
||||
var itemSlug = !string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid)
|
||||
? StoreService.EquipmentSkinSlug(gift.EquipmentPrefabName, gift.EquipmentModificationGuid)
|
||||
: gift.EquipmentPrefabName;
|
||||
if (!string.IsNullOrWhiteSpace(itemSlug))
|
||||
{
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == itemSlug);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerInventory.Add(new PlayerInventoryEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
ItemSlug = itemSlug,
|
||||
Quantity = 1,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Quantity = Math.Max(row.Quantity, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply currency / XP / level rewards bundled with the gift.
|
||||
// The previous implementation only handled the avatar item path,
|
||||
// so token gifts from the admin SPA marked themselves consumed
|
||||
@@ -336,13 +227,17 @@ public class AvatarGiftsController(
|
||||
gift.ConsumedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// The wallet readout refreshes off the StorefrontBalanceUpdate that
|
||||
// GrantCurrencyAsync (above) already pushes for currency gifts. Do
|
||||
// NOT also push SubscriptionUpdateProfile ("AccountUpdate") with a
|
||||
// {Reason,Currency,…} blob: the 2023 client deserialises that channel
|
||||
// as its Account DTO, so a non-account payload decodes to a blank
|
||||
// account (accountId 0) → "Orphan player account (0) found" spam
|
||||
// (audited 2026-07-12).
|
||||
// Profile-update push so the watch's wallet readout refreshes
|
||||
// without waiting for the next /balance poll. Mirrors what
|
||||
// /api/admin/v1/players/{id}/gift does after the inbox push so
|
||||
// the toast number animates up the moment the box closes.
|
||||
if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0)
|
||||
{
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level });
|
||||
}
|
||||
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
@@ -357,45 +252,14 @@ public class AvatarGiftsController(
|
||||
_ => 50, // Legendary
|
||||
};
|
||||
|
||||
// Wire shape verified against the 2020.12 watch's
|
||||
// NLEKGNENMCO+LOCNECLOHCA gift DTO deserializer (Cpp2IL ISIL
|
||||
// NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946). All keys read
|
||||
// via Util.GetKey<T> (mandatory): Id, Message, FromPlayerId,
|
||||
// AvatarItemType, AvatarItemDesc, ConsumableItemDesc,
|
||||
// EquipmentPrefabName, EquipmentModificationGuid, Platform,
|
||||
// PlatformsToSpawnOn, BalanceType, CurrencyType, Currency, Xp,
|
||||
// GiftRarity, GiftContext. Missing any one → KeyNotFoundException
|
||||
// surfaces as "Failed to download gifts: Malformed Response" from
|
||||
// BootSequence and BLOCKS LOGIN.
|
||||
/// <summary>The desc the client matches against api/avatar/v4/items in
|
||||
/// OutfitManager.UnlockAvatarItemAndMarkNew after the gift box is
|
||||
/// consumed. That endpoint emits outfit items as the 4-part "{guid},,,"
|
||||
/// form (a bare guid crashes the client's AvatarItem parser), and the
|
||||
/// match is an EXACT string compare — so an outfit gift whose desc has
|
||||
/// fewer than 4 comma-parts (legacy rows stored the bare guid) makes
|
||||
/// Find() miss and NRE, which strips the item on consume. Pad outfit
|
||||
/// descs to 4 parts here so BOTH new and already-persisted gifts match.
|
||||
/// Hair dyes (AvatarItemType 1) match by bare colour guid — leave them;
|
||||
/// currency-only gifts have an empty desc.</summary>
|
||||
private static string NormalizeAvatarWireDesc(GiftPackageEntity g)
|
||||
private static object ToWire(GiftPackageEntity g) => new
|
||||
{
|
||||
var d = g.AvatarItemDescOrHairDyeDesc ?? string.Empty;
|
||||
if (d.Length == 0 || g.AvatarItemType == 1) return d;
|
||||
var parts = d.Split(',').Length;
|
||||
return parts >= 4 ? d : d + new string(',', 4 - parts);
|
||||
}
|
||||
|
||||
public static object ToWire(GiftPackageEntity g)
|
||||
{
|
||||
var avatarDesc = NormalizeAvatarWireDesc(g);
|
||||
return new
|
||||
{
|
||||
g.Id,
|
||||
g.FromPlayerId,
|
||||
g.ConsumableItemDesc,
|
||||
g.AvatarItemType,
|
||||
AvatarItemDesc = avatarDesc,
|
||||
AvatarItemDescOrHairDyeDesc = avatarDesc,
|
||||
AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc,
|
||||
g.AvatarItemDescOrHairDyeDesc,
|
||||
g.EquipmentPrefabName,
|
||||
g.EquipmentModificationGuid,
|
||||
g.CurrencyType,
|
||||
@@ -406,23 +270,6 @@ public class AvatarGiftsController(
|
||||
g.GiftRarity,
|
||||
g.Message,
|
||||
g.Platform,
|
||||
// PlatformsToSpawnOn — required by the watch's gift DTO
|
||||
// deserializer. This is a SINGLE int (4-byte field at offset
|
||||
// 0x6C in LOCNECLOHCA, separate enum from Platform itself).
|
||||
// The watch reads it with strict Util.GetKey<int>, so sending
|
||||
// an empty array crashes Convert.ChangeType([], typeof(int))
|
||||
// with "Object must implement IConvertible". -1 mirrors the
|
||||
// sibling Platform field's "every platform" sentinel — no
|
||||
// platform-gated gift restrictions on a private server.
|
||||
PlatformsToSpawnOn = -1,
|
||||
// BalanceType — required mandatory key on the gift DTO. The
|
||||
// enum identifies whether the gift's payload is a Currency
|
||||
// balance update (0) vs Inventory item (1) vs XP (2). For
|
||||
// currency-bearing gifts we'd send 0; the safe default for
|
||||
// our seed gifts (which are item-only) is 0 too since the
|
||||
// watch only reads it to dispatch the balance-update side
|
||||
// and we never carry one. Wrong type doesn't crash.
|
||||
BalanceType = 0,
|
||||
g.PackageMaterial,
|
||||
g.PackageVariant,
|
||||
g.Consumed,
|
||||
@@ -430,6 +277,5 @@ public class AvatarGiftsController(
|
||||
g.ErrorMessage,
|
||||
g.SupportsCurrentPlatform,
|
||||
g.IsGifted,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,8 +78,6 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
string? hairColor = null;
|
||||
string? skinColor = null;
|
||||
string? faceFeatures = null;
|
||||
string? outfitSelectionsV2 = null;
|
||||
string? customAvatarItems = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
@@ -93,11 +91,6 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
hairColor = ReadStringField(root, "HairColor");
|
||||
skinColor = ReadStringField(root, "SkinColor");
|
||||
faceFeatures = ReadStringField(root, "FaceFeatures");
|
||||
// 2023 client (HHDLNAPEMGP writer EKHJKDNHOPB) also
|
||||
// uploads OutfitSelectionsV2 + CustomAvatarItems —
|
||||
// persist them so remote-player fetches round-trip.
|
||||
outfitSelectionsV2 = ReadStringField(root, "OutfitSelectionsV2");
|
||||
customAvatarItems = ReadStringField(root, "CustomAvatarItems");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
@@ -123,16 +116,6 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
if (hairColor is not null) a.HairColor = hairColor;
|
||||
if (skinColor is not null) a.SkinColor = skinColor;
|
||||
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
|
||||
if (outfitSelectionsV2 is not null) a.OutfitSelectionsV2 = outfitSelectionsV2;
|
||||
if (customAvatarItems is not null)
|
||||
{
|
||||
// CustomAvatarItems arrives as a JSON ARRAY of
|
||||
// {"CustomAvatarItemId":guid,"BodyPart":byte}; ReadStringField
|
||||
// re-encodes arrays as raw JSON. Guard against non-array junk.
|
||||
a.CustomAvatarItemsJson = customAvatarItems.TrimStart().StartsWith('[')
|
||||
? customAvatarItems
|
||||
: "[]";
|
||||
}
|
||||
a.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToDto(a));
|
||||
@@ -157,25 +140,6 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/avatar/v2/{accountId}</c> — the 2023 client's
|
||||
/// remote-player avatar settings fetch. Decompile evidence
|
||||
/// (2023.03.21 ISIL, RecNet.Runtime FFFIMAGLKEG.txt:4845-5006,
|
||||
/// method KAFJNMDBKDB): URL is String.Format("{0}v2/{1}",
|
||||
/// "api/avatar/", accountId) — the account id is a bare path segment
|
||||
/// directly under v2 (NOT under /equipped/). The response is parsed
|
||||
/// as object HHDLNAPEMGP whose reader (EKHJKDNHOPB.txt:471-766)
|
||||
/// registers OutfitSelections / OutfitSelectionsV2 / FaceFeatures /
|
||||
/// SkinColor / HairColor / CustomAvatarItems. Without this route the
|
||||
/// request 404'd with an empty body → "Failed to load Rec Room
|
||||
/// remote player avatar settings" (Player.log:2597).</summary>
|
||||
[HttpGet("api/avatar/v2/{playerId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetSettingsFor(long playerId)
|
||||
{
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>Public lookup of another player's equipped avatar — the
|
||||
/// client uses this to render remote players' avatars in shared
|
||||
/// rooms. No auth requirement on read so anonymous calls in the
|
||||
@@ -210,27 +174,7 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
FaceFeatures = a?.FaceFeatures ?? string.Empty,
|
||||
HairColor = a?.HairColor ?? string.Empty,
|
||||
SkinColor = a?.SkinColor ?? string.Empty,
|
||||
OutfitSelectionsV2 = a?.OutfitSelectionsV2 ?? string.Empty,
|
||||
CustomAvatarItems = ParseCustomAvatarItems(a?.CustomAvatarItemsJson),
|
||||
};
|
||||
|
||||
/// <summary>Re-hydrate the stored CustomAvatarItems JSON into typed
|
||||
/// wire items so the response always carries a well-formed array
|
||||
/// (never a raw string). Bad/legacy blobs degrade to [].</summary>
|
||||
private static List<CustomAvatarItemRefDto> ParseCustomAvatarItems(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<CustomAvatarItemRefDto>>(
|
||||
json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class AvatarV2Dto
|
||||
@@ -246,28 +190,4 @@ public class AvatarV2Dto
|
||||
|
||||
[JsonPropertyName("FaceFeatures")]
|
||||
public string FaceFeatures { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>2023 client field — HHDLNAPEMGP reader key
|
||||
/// "OutfitSelectionsV2" (EKHJKDNHOPB.txt:498-514). Extra keys are
|
||||
/// skipped by the 2020 reader, so this is safe cross-version.</summary>
|
||||
[JsonPropertyName("OutfitSelectionsV2")]
|
||||
public string OutfitSelectionsV2 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>2023 client field — HHDLNAPEMGP reader key
|
||||
/// "CustomAvatarItems" (EKHJKDNHOPB.txt:594-610), a list of
|
||||
/// BGNNOMBFMLH items.</summary>
|
||||
[JsonPropertyName("CustomAvatarItems")]
|
||||
public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>BGNNOMBFMLH — { Guid CustomAvatarItemId, Byte BodyPart }
|
||||
/// (property types BGNNOMBFMLH.txt:3-39; JSON keys registered by reader
|
||||
/// MKAIABAJIAJ.txt:215-258).</summary>
|
||||
public class CustomAvatarItemRefDto
|
||||
{
|
||||
[JsonPropertyName("CustomAvatarItemId")]
|
||||
public Guid CustomAvatarItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("BodyPart")]
|
||||
public byte BodyPart { get; set; }
|
||||
}
|
||||
|
||||
@@ -62,10 +62,7 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
/// Anonymous calls return [].
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AvatarItemsController(
|
||||
DorkNetDbContext db,
|
||||
IConfiguration config,
|
||||
ServerSettingsService serverSettings) : ControllerBase
|
||||
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
|
||||
{
|
||||
/// <summary>Master catalog keyed by GUID. Loaded once on first
|
||||
/// request from <c>data/avatar_item_lookup.json</c>. Same
|
||||
@@ -88,26 +85,8 @@ public class AvatarItemsController(
|
||||
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
|
||||
return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
// The old [1,120] / [1,20] ceilings reflected the 2020 *watch's*
|
||||
// render-budget headroom — feeding it too many BrowsableAvatarItem /
|
||||
// DynamicAvatarItemImposter rows could crash its wardrobe drawer.
|
||||
// This branch (march-2023-03-21) serves the 2023 DESKTOP client, which
|
||||
// has no such limit, and the watch-era cap was silently truncating the
|
||||
// owned wardrobe to 100 ("most of my items don't show"). Raise the
|
||||
// ceilings well above the full catalog (882 base items + variants) so
|
||||
// nothing the player owns is dropped; the config keys still let an
|
||||
// operator dial it back down for a watch build.
|
||||
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 4000), 1, 8000);
|
||||
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 1000), 1, 2000);
|
||||
|
||||
// "Everyone owns all avatar items" admin toggle (mirrors the
|
||||
// "everyone is friends" one): report the ENTIRE master catalog plus
|
||||
// all permanent hair dyes as owned, for every caller, without reading
|
||||
// or writing any inventory row. Flipping the toggle off instantly
|
||||
// reverts to the real per-player wardrobe below. Bounded by the same
|
||||
// maxItems ceiling so a future watch build can't be over-fed.
|
||||
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
|
||||
return Ok(BuildEntireCatalogOwned(maxItems));
|
||||
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120);
|
||||
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20);
|
||||
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
@@ -117,7 +96,7 @@ public class AvatarItemsController(
|
||||
.Select(a => new { a.InventoryJson, a.OutfitSelections })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var owned = ParseInventoryEntries(avatar?.InventoryJson);
|
||||
var owned = ParseInventory(avatar?.InventoryJson);
|
||||
var ownedHairDyeSlugs = await db.PlayerInventory
|
||||
.Where(i => i.PlayerId == me && i.Quantity > 0)
|
||||
.Select(i => i.ItemSlug)
|
||||
@@ -130,57 +109,28 @@ public class AvatarItemsController(
|
||||
|
||||
var catalog = _catalog.Value;
|
||||
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
|
||||
// Recency tiebreaker: GrantWardrobeAsync / gift-consume APPEND new
|
||||
// GUIDs to the end of InventoryJson, so the original index is also
|
||||
// the purchase order. When per-slot truncation has to drop entries,
|
||||
// we want it to drop the OLDEST (starter seed) items rather than the
|
||||
// one the user just bought.
|
||||
var ordered = owned
|
||||
.Select((entry, idx) => (Entry: entry, OriginalIndex: idx))
|
||||
.OrderByDescending(x => equipped.Contains(x.Entry.BaseGuid))
|
||||
.ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Entry.BaseGuid, out var slot) ? slot : int.MaxValue)
|
||||
.ThenByDescending(x => x.OriginalIndex)
|
||||
.Select(x => x.Entry);
|
||||
.OrderByDescending(guid => equipped.Contains(guid))
|
||||
.ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue)
|
||||
.ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
|
||||
var perSlot = new Dictionary<int, int>();
|
||||
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
|
||||
foreach (var entry in ordered)
|
||||
foreach (var guid in ordered)
|
||||
{
|
||||
if (result.Count >= maxOutfitItems) break;
|
||||
// Intersect against the safelist. Items not in the bundled
|
||||
// lookup would crash the watch's wardrobe parser, so they're
|
||||
// silently dropped here. The catalog is keyed by the BARE GUID,
|
||||
// so colored variants (stored as "{guid},{swatch},{mask},") must
|
||||
// look up by BaseGuid — keying by the full comma string would
|
||||
// miss and silently drop every owned colored item (the contest
|
||||
// hat "I bought it but it never shows in my wardrobe" bug).
|
||||
if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue;
|
||||
// silently dropped here.
|
||||
if (!catalog.Items.TryGetValue(guid, out var item)) continue;
|
||||
|
||||
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
|
||||
var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1;
|
||||
perSlot.TryGetValue(slot, out var count);
|
||||
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue;
|
||||
if (count >= maxPerSlot && !equipped.Contains(guid)) continue;
|
||||
|
||||
perSlot[slot] = count + 1;
|
||||
// Preserve the bought color: a bare entry (FullDesc == BaseGuid)
|
||||
// emits the catalog DTO as-is ("{guid},,,"); a colored entry
|
||||
// overrides AvatarItemDesc with the stored swatch/mask desc so
|
||||
// the watch renders the variant the player paid for instead of
|
||||
// falling back to the default swatch.
|
||||
result.Add(string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase)
|
||||
? item
|
||||
: new UnlockedAvatarItemDto
|
||||
{
|
||||
AvatarItemType = item.AvatarItemType,
|
||||
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
||||
AvatarItemId = item.AvatarItemId,
|
||||
PlatformMask = item.PlatformMask,
|
||||
FriendlyName = item.FriendlyName,
|
||||
Tooltip = item.Tooltip,
|
||||
Rarity = item.Rarity,
|
||||
TagList = item.TagList,
|
||||
IsBaseAvatarItem = item.IsBaseAvatarItem,
|
||||
});
|
||||
result.Add(item);
|
||||
if (result.Count >= maxItems) break;
|
||||
}
|
||||
|
||||
@@ -193,96 +143,15 @@ public class AvatarItemsController(
|
||||
{
|
||||
AvatarItemType = AvatarItemType.HairDye,
|
||||
AvatarItemDesc = hairColorGuid,
|
||||
AvatarItemId = StableAvatarItemId(hairColorGuid),
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = friendlyName,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
TagList = string.Empty,
|
||||
IsBaseAvatarItem = false,
|
||||
});
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The full owned set when the "everyone owns all avatar
|
||||
/// items" toggle is on: every catalog item (already carrying the
|
||||
/// "{guid},,," desc) plus every permanent hair dye, capped at
|
||||
/// <paramref name="maxItems"/>. Hair dyes are emitted in the same
|
||||
/// shape the per-player path uses (AvatarItemType.HairDye, desc =
|
||||
/// hair-color GUID) so the wardrobe renders them identically.</summary>
|
||||
private static List<UnlockedAvatarItemDto> BuildEntireCatalogOwned(int maxItems)
|
||||
{
|
||||
var catalog = _catalog.Value;
|
||||
var result = new List<UnlockedAvatarItemDto>(Math.Min(maxItems, catalog.Items.Count + 32));
|
||||
foreach (var item in catalog.Items.Values)
|
||||
{
|
||||
if (result.Count >= maxItems) return result;
|
||||
result.Add(item);
|
||||
}
|
||||
// Colored variants aren't in the base avatar_item_lookup catalog, so
|
||||
// without this the "everyone owns everything" wardrobe was missing all
|
||||
// ~1.8k colorways — the player didn't own them AND buying one gifted a
|
||||
// variant desc ("{guid},{combo},{mask},") that wasn't in this list, so
|
||||
// the client's post-consume UnlockAvatarItemAndMarkNew Find() missed
|
||||
// and NRE'd (the item vanished on buy). Emit each variant with its
|
||||
// verbatim equip descriptor and a UNIQUE id hashed from the FULL
|
||||
// descriptor (the base-guid hash would collide with the base item and
|
||||
// every sibling colorway).
|
||||
foreach (var v in StoreService.ColorVariantCatalog.Values)
|
||||
{
|
||||
if (result.Count >= maxItems) return result;
|
||||
if (string.IsNullOrWhiteSpace(v.descriptor)) continue;
|
||||
result.Add(new UnlockedAvatarItemDto
|
||||
{
|
||||
AvatarItemType = AvatarItemType.Outfit,
|
||||
AvatarItemDesc = v.descriptor,
|
||||
AvatarItemId = StableAvatarItemIdFull(v.descriptor),
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = v.item_name ?? string.Empty,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
TagList = string.Empty,
|
||||
IsBaseAvatarItem = false,
|
||||
});
|
||||
}
|
||||
foreach (var slug in StoreService.AllHairDyeSlugs)
|
||||
{
|
||||
if (result.Count >= maxItems) break;
|
||||
if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue;
|
||||
StoreService.TryGetHairDyeName(slug, out var friendlyName);
|
||||
result.Add(new UnlockedAvatarItemDto
|
||||
{
|
||||
AvatarItemType = AvatarItemType.HairDye,
|
||||
AvatarItemDesc = hairColorGuid,
|
||||
AvatarItemId = StableAvatarItemId(hairColorGuid),
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = friendlyName,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
TagList = string.Empty,
|
||||
IsBaseAvatarItem = false,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult DefaultBaseAvatarItems()
|
||||
{
|
||||
var rows = _catalog.Value.Items.Values
|
||||
.OrderBy(i => i.AvatarItemType)
|
||||
.ThenBy(i => i.FriendlyName)
|
||||
.Take(100)
|
||||
.ToList();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("api/avatar/v1/defaultunlocked")]
|
||||
[AllowAnonymous]
|
||||
public Task<ActionResult<List<UnlockedAvatarItemDto>>> DefaultUnlocked() => Get();
|
||||
|
||||
/// <summary>The set of GUIDs the bundled
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// contains. Exposed so other controllers (StoreService seed,
|
||||
@@ -297,55 +166,6 @@ public class AvatarItemsController(
|
||||
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
|
||||
=> _catalog.Value.Items;
|
||||
|
||||
/// <summary>GET <c>api/avatar/v1/lockeditems</c> — metadata for
|
||||
/// specific avatar item descriptions the caller does not own. The
|
||||
/// store UI calls this with item descs from purchasable rows and
|
||||
/// expects the same item-detail shape used by unlocked items,
|
||||
/// minus ownership. We resolve against the safe runtime catalog so
|
||||
/// unknown GUIDs never reach the client parser.</summary>
|
||||
[HttpGet("api/avatar/v1/lockeditems")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> LockedItems()
|
||||
{
|
||||
var requested = RequestedAvatarItemGuids().ToList();
|
||||
if (requested.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
// When everyone owns everything, nothing is "locked" — the store's
|
||||
// owned/locked diff must see an empty locked set so every tile reads
|
||||
// as owned, consistent with the full wardrobe returned by Get().
|
||||
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is long me)
|
||||
{
|
||||
var invJson = await db.Avatars
|
||||
.Where(a => a.PlayerId == me)
|
||||
.Select(a => a.InventoryJson)
|
||||
.FirstOrDefaultAsync();
|
||||
foreach (var guid in ParseInventory(invJson))
|
||||
owned.Add(guid);
|
||||
}
|
||||
|
||||
var catalog = _catalog.Value.Items;
|
||||
var rows = requested
|
||||
.Where(guid => !owned.Contains(guid))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
|
||||
.Where(item => item is not null)
|
||||
.Select(item => new
|
||||
{
|
||||
item!.AvatarItemType,
|
||||
item.AvatarItemDesc,
|
||||
item.FriendlyName,
|
||||
item.Tooltip,
|
||||
item.Rarity,
|
||||
})
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
|
||||
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
|
||||
/// build a starter loadout (one per slot) and a starter inventory
|
||||
@@ -356,111 +176,49 @@ public class AvatarItemsController(
|
||||
public static bool TryGetOutfitType(string guid, out int outfitType) =>
|
||||
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
|
||||
|
||||
private IEnumerable<string> RequestedAvatarItemGuids()
|
||||
private static List<string> ParseInventory(string? json)
|
||||
{
|
||||
foreach (var pair in Request.Query)
|
||||
foreach (var raw in pair.Value)
|
||||
foreach (var part in (raw ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var value = part.Trim();
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
var comma = value.IndexOf(',');
|
||||
if (comma >= 0) value = value[..comma];
|
||||
if (Guid.TryParse(value, out _)) yield return value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An owned wardrobe entry: the bare AvatarItem GUID used as
|
||||
/// the catalog / slot / equipped key, plus the full RecNet desc exactly
|
||||
/// as stored in InventoryJson. For a plain item the two are identical;
|
||||
/// for a colored variant the desc is "{guid},{swatch},{mask}," so the
|
||||
/// swatch the player paid for survives the round-trip to the wardrobe.</summary>
|
||||
private readonly record struct OwnedEntry(string BaseGuid, string FullDesc);
|
||||
|
||||
/// <summary>Parse InventoryJson into normalized owned entries. Accepts
|
||||
/// both the plain JSON string array of descs (the shape
|
||||
/// GrantWardrobeAsync / PurchaseAsync write) and the legacy
|
||||
/// [{itemId,...}] object shape. Strips a leading "wardrobe-" slug
|
||||
/// prefix, derives the bare GUID (substring before the first comma) for
|
||||
/// catalog lookups, and keeps the full desc so a colored variant keeps
|
||||
/// its swatch. Dedupes by full desc, so multiple colors of the same
|
||||
/// base item remain distinct wardrobe tiles while exact duplicates
|
||||
/// collapse.</summary>
|
||||
private static List<OwnedEntry> ParseInventoryEntries(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
|
||||
|
||||
var raw = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
var strings = JsonSerializer.Deserialize<List<string>>(json);
|
||||
if (strings is not null) raw.AddRange(strings);
|
||||
if (strings is not null) return strings;
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (raw.Count == 0)
|
||||
try
|
||||
{
|
||||
try
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
||||
|
||||
var result = new List<string>();
|
||||
foreach (var element in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
||||
foreach (var element in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
raw.Add(element.GetString() ?? string.Empty);
|
||||
else if (element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty("itemId", out var itemId) &&
|
||||
itemId.ValueKind == JsonValueKind.String)
|
||||
raw.Add(itemId.GetString() ?? string.Empty);
|
||||
}
|
||||
string? value = null;
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
value = element.GetString();
|
||||
else if (element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty("itemId", out var itemId) &&
|
||||
itemId.ValueKind == JsonValueKind.String)
|
||||
value = itemId.GetString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
const string wardrobePrefix = "wardrobe-";
|
||||
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
|
||||
value = value[wardrobePrefix.Length..];
|
||||
var comma = value.IndexOf(',');
|
||||
if (comma >= 0)
|
||||
value = value[..comma];
|
||||
result.Add(value);
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
const string wardrobePrefix = "wardrobe-";
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var result = new List<OwnedEntry>();
|
||||
foreach (var entry in raw)
|
||||
{
|
||||
var value = entry;
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
|
||||
value = value[wardrobePrefix.Length..];
|
||||
var comma = value.IndexOf(',');
|
||||
var baseGuid = comma >= 0 ? value[..comma] : value;
|
||||
if (string.IsNullOrWhiteSpace(baseGuid)) continue;
|
||||
if (!seen.Add(value)) continue;
|
||||
result.Add(new OwnedEntry(baseGuid, value));
|
||||
return result
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
return result;
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
/// <summary>Bare-GUID view of the owned set, for callers that only need
|
||||
/// ownership membership (e.g. the locked-items diff), not the swatch.</summary>
|
||||
private static List<string> ParseInventory(string? json) =>
|
||||
ParseInventoryEntries(json)
|
||||
.Select(e => e.BaseGuid)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Ensure a stored desc carries the ≥4 comma-separated parts
|
||||
/// (guid + 3 customizations) that the watch's
|
||||
/// <c>AvatarItem.FromRecNetString</c> requires — anything shorter throws
|
||||
/// inside the wardrobe parser. Stored colored descs are already
|
||||
/// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy
|
||||
/// value.</summary>
|
||||
// A stored colored descriptor is the EXACT RecNet equip string
|
||||
// ({itemGuid},{combinationGuid…}) — the client feeds everything after the
|
||||
// item GUID into combinationLookup.TryGetValue as an opaque, case-sensitive
|
||||
// key, so it must be returned byte-for-byte. Do NOT pad to 3 commas: the
|
||||
// 2023 combination model has short-form descriptors ({item},{combo}) whose
|
||||
// trailing commas are significant — padding "{item},{combo}" to
|
||||
// "{item},{combo},," corrupts the key and the watch fails to resolve the
|
||||
// combination (renders a broken/default mesh). Only genuinely bare entries
|
||||
// (FullDesc == BaseGuid) get the "{guid},,," base form, and those never
|
||||
// reach here (the caller emits the catalog DTO directly for them).
|
||||
private static string ToWatchDesc(string storedDesc) => storedDesc;
|
||||
|
||||
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -524,13 +282,10 @@ public class AvatarItemsController(
|
||||
{
|
||||
if (string.IsNullOrEmpty(row.Guid)) continue;
|
||||
safe.Add(row.Guid);
|
||||
// Prefer the baked display_name from the JSON if present
|
||||
// (tools/add-display-names.py writes it once per refresh).
|
||||
// Falls back to the raw-name cleanup if the field is missing
|
||||
// — e.g. partial dumps or older fixture files.
|
||||
var pretty = !string.IsNullOrEmpty(row.DisplayName)
|
||||
? row.DisplayName
|
||||
: PrettyName(row.Name);
|
||||
// Rec Room's bundled item names are wrapped in parens like
|
||||
// "(Hat_Wizard)" — strip those and the underscores for a
|
||||
// friendlier UI label.
|
||||
var pretty = PrettyName(row.Name);
|
||||
items[row.Guid] = new UnlockedAvatarItemDto
|
||||
{
|
||||
// AvatarItemType is the wire enum the watch parses
|
||||
@@ -541,13 +296,10 @@ public class AvatarItemsController(
|
||||
// GUID as a color GUID and log "Can not find hair dye color".
|
||||
AvatarItemType = AvatarItemType.Outfit,
|
||||
AvatarItemDesc = row.Guid + ",,,",
|
||||
AvatarItemId = StableAvatarItemId(row.Guid),
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = pretty,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
TagList = string.Empty,
|
||||
IsBaseAvatarItem = false,
|
||||
};
|
||||
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
||||
bySlot[row.OutfitType] = lst = new List<string>();
|
||||
@@ -558,33 +310,6 @@ public class AvatarItemsController(
|
||||
return new CatalogData(items, safe, bySlot, slotByGuid);
|
||||
}
|
||||
|
||||
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned
|
||||
/// avatar item, derived from its base GUID (FNV-1a). The 2023 client
|
||||
/// keys the wardrobe by <c>AvatarItemId</c>, so it must be unique per
|
||||
/// item and stable across restarts. Colored variants reuse their base
|
||||
/// item's id (a player owns the base OR a variant, never both in the
|
||||
/// list), so no collision.</summary>
|
||||
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
|
||||
{
|
||||
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
|
||||
uint h = 2166136261u;
|
||||
foreach (var c in guid) h = (h ^ c) * 16777619u;
|
||||
var id = (int)(h & 0x7FFFFFFF);
|
||||
return id == 0 ? 1 : id;
|
||||
}
|
||||
|
||||
/// <summary>Stable id from the FULL descriptor string (not just the base
|
||||
/// guid). Colored variants share a base guid but are distinct wardrobe
|
||||
/// entries, so they must hash the whole "{guid},{combo},{mask}," string
|
||||
/// to get ids distinct from the base item and each other.</summary>
|
||||
public static int StableAvatarItemIdFull(string? descriptor)
|
||||
{
|
||||
uint h = 2166136261u;
|
||||
foreach (var c in descriptor ?? string.Empty) h = (h ^ c) * 16777619u;
|
||||
var id = (int)(h & 0x7FFFFFFF);
|
||||
return id == 0 ? 1 : id;
|
||||
}
|
||||
|
||||
private static string PrettyName(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return string.Empty;
|
||||
@@ -624,22 +349,11 @@ public class AvatarItemsController(
|
||||
{
|
||||
[JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty;
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
||||
// Algorithmically derived from `name` by
|
||||
// tools/add-display-names.py — Rec Room's official UI strings
|
||||
// live in Unity asset bundles we can't read from a code dump,
|
||||
// so this is a best-effort cleanup. Optional — server falls
|
||||
// back to PrettyName() if missing.
|
||||
[JsonPropertyName("display_name")] public string? DisplayName { get; set; }
|
||||
[JsonPropertyName("outfit_type")] public int OutfitType { get; set; }
|
||||
[JsonPropertyName("outfit_set")] public int OutfitSet { get; set; }
|
||||
[JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; }
|
||||
[JsonPropertyName("hides_hair")] public bool HidesHair { get; set; }
|
||||
[JsonPropertyName("hides_hand")] public bool HidesHand { get; set; }
|
||||
// hides_left_ear / hides_right_ear were added to AvatarItemData
|
||||
// in the 2020.12.18 client build. Optional in the JSON so older
|
||||
// dumps still deserialize.
|
||||
[JsonPropertyName("hides_left_ear")] public bool HidesLeftEar { get; set; }
|
||||
[JsonPropertyName("hides_right_ear")] public bool HidesRightEar { get; set; }
|
||||
[JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; }
|
||||
[JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; }
|
||||
[JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; }
|
||||
@@ -683,16 +397,6 @@ public class UnlockedAvatarItemDto
|
||||
[JsonPropertyName("AvatarItemDesc")]
|
||||
public string AvatarItemDesc { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The 2023 client's owned-item DTO (RecNet IFNGBPJGINL) reads
|
||||
/// <c>AvatarItemId</c> as an <b>Int32</b>. Omitting it left every owned
|
||||
/// item at id 0; the client keys its wardrobe by AvatarItemId, so a
|
||||
/// second item collided and the whole unlocked-items list failed to
|
||||
/// build — the wardrobe showed nothing owned and buying (which refetches
|
||||
/// this list) NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew. Emit a
|
||||
/// stable, unique, non-zero id.</summary>
|
||||
[JsonPropertyName("AvatarItemId")]
|
||||
public int AvatarItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("PlatformMask")]
|
||||
public int PlatformMask { get; set; }
|
||||
|
||||
@@ -704,15 +408,4 @@ public class UnlockedAvatarItemDto
|
||||
|
||||
[JsonPropertyName("Rarity")]
|
||||
public int Rarity { get; set; }
|
||||
|
||||
/// <summary>CSV tag string (IFNGBPJGINL.TagList). Empty is fine —
|
||||
/// AvatarItemMetadata.ParseTagList tolerates it — but the key must be
|
||||
/// present and a string, never null.</summary>
|
||||
[JsonPropertyName("TagList")]
|
||||
public string TagList { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this is a default base-body item vs a cosmetic
|
||||
/// (IFNGBPJGINL.IsBaseAvatarItem). False for wardrobe/catalog items.</summary>
|
||||
[JsonPropertyName("IsBaseAvatarItem")]
|
||||
public bool IsBaseAvatarItem { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,511 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Comments;
|
||||
|
||||
/// <summary>
|
||||
/// Room comments ("feedback"/"idea" notes players leave in a room, shown by
|
||||
/// the watch's RoomCommentsScreen / RoomCommentsPagedGrid).
|
||||
///
|
||||
/// Every route here is issued by RecNet.Runtime's room-comment service
|
||||
/// <c>OPELMBNJHNO</c> against service host <c>12 = RoomComments</c>
|
||||
/// (<see cref="Services.ConfigService"/> maps that to the <c>roomcomments</c>
|
||||
/// subdomain). The five literals, with the <c>BNDIAONDFFF</c> ctor verb
|
||||
/// ordinal moved into <c>rdx</c> immediately before the builder call
|
||||
/// (BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT, 4=DELETE):
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><c>OPELMBNJHNO.txt:485</c> "comments/get/{0}" — rdx=0 (GET), host [rdx+12]</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:811</c> "comments/unreadcounts" — dynamic GET/POST</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:1216</c> "comments/create/{0}" — rdx=2 (POST), host [r12+12]</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:1627</c> "comments/read/{0}/{1}" — rdx=3 (PUT), host [rbx+12]</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:1935</c> "comments/delete/{0}" — rdx=4 (DELETE), r8=12</item>
|
||||
/// </list>
|
||||
///
|
||||
/// STORAGE. There is no <c>RoomCommentEntity</c> and adding entities
|
||||
/// needs a migration, so comments live in the <see cref="PlayerSettingEntity"/>
|
||||
/// side-table exactly like the PlayerEvent extras
|
||||
/// (<c>PlayerEventsController.EventExtras</c>): one row per comment with
|
||||
/// <c>PlayerId</c> = the author, <c>Key = roomcomment:{roomId}:{commentId}</c>
|
||||
/// and <c>Value</c> = the JSON payload. Per-player read state is a second,
|
||||
/// disjoint namespace — <c>roomcommentread:{roomId}</c> holding the last-read
|
||||
/// comment id — which is what finally lets
|
||||
/// <see cref="UnreadCounts"/> report real numbers instead of zeroes.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class CommentsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
/// <summary>Key prefix for a stored comment: <c>roomcomment:{roomId}:{commentId}</c>.</summary>
|
||||
private const string CommentPrefix = "roomcomment:";
|
||||
|
||||
/// <summary>Key prefix for per-player read state: <c>roomcommentread:{roomId}</c>,
|
||||
/// value = last-read comment id. Deliberately a different prefix from
|
||||
/// <see cref="CommentPrefix"/> (not <c>roomcomment:read:…</c>) so the
|
||||
/// suffix match used by <see cref="Delete"/> can never hit a read-state row.</summary>
|
||||
private const string ReadPrefix = "roomcommentread:";
|
||||
|
||||
/// <summary>The <c>RecNet.RoomComment</c> members that are not already
|
||||
/// implied by the storage row's key/owner. <c>Position</c> is three separate
|
||||
/// nullable floats on the wire, not a Vector3: the client's
|
||||
/// <c>Nullable<Vector3></c> property is <c>[IgnoreDataMember]</c> and is
|
||||
/// rebuilt after deserialisation from three internal
|
||||
/// <c>Nullable<float></c> fields (RecNet/RoomComment.txt:495
|
||||
/// <c>FKDDCNLJOLF</c> reads the HasValue byte at <c>[rbx+0x6C]</c>, i.e. the
|
||||
/// first of the three at 0x68/0x70/0x78).
|
||||
///
|
||||
/// Public (not private) so System.Text.Json's reflection-based
|
||||
/// (de)serialiser can see the type and its accessors.</summary>
|
||||
public sealed class CommentPayload
|
||||
{
|
||||
public long? SubRoomId { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
/// <summary><c>RoomComment.EKHGDABMJOJ</c>: Feedback=0, Idea=1, BugReport=2
|
||||
/// (RecNet/RoomComment.cs enum member order).</summary>
|
||||
public int Style { get; set; }
|
||||
public float? X { get; set; }
|
||||
public float? Y { get; set; }
|
||||
public float? Z { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Comment list for a room.
|
||||
///
|
||||
/// <c>OPELMBNJHNO.GMBPJHCBPDP(Int64 roomId, Int32 count, Int64 minId)</c>
|
||||
/// returns <c>FGLDKEJLAKB<List<RecNet.RoomComment>></c>
|
||||
/// (OPELMBNJHNO.txt:341), so the body is a bare JSON <b>array</b> — not a
|
||||
/// paged <c>{Results,TotalResults}</c> container. Verb is GET (rdx=0 at
|
||||
/// instr 044) and both parameters go on the query string, because
|
||||
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> only promotes fields to a form body for
|
||||
/// non-GET verbs: literals "count" (:506) and "minId" (:518).
|
||||
///
|
||||
/// <c>minId</c> is a lower bound on the comment id (the only caller,
|
||||
/// FeedbackManager.txt:948, passes count=100 and a carried-forward id), so
|
||||
/// it is applied inclusively and the newest <c>count</c> matches are
|
||||
/// returned newest-first — which is the order the paged grid renders.
|
||||
///
|
||||
/// Failure toast if this 404s or the body will not parse:
|
||||
/// "Failed to get room comments for room: {0}" (:537).</summary>
|
||||
[HttpGet("/comments/get/{roomId:long}")]
|
||||
public async Task<IActionResult> Get(long roomId, [FromQuery] int count = 100, [FromQuery] long minId = 0)
|
||||
{
|
||||
if (count <= 0) count = 100;
|
||||
if (count > 500) count = 500;
|
||||
|
||||
var rows = await CommentRowsForRoomAsync(roomId);
|
||||
|
||||
var wire = rows
|
||||
.Where(r => r.CommentId >= minId)
|
||||
.OrderByDescending(r => r.CommentId)
|
||||
.Take(count)
|
||||
.Select(r => ToWire(r.CommentId, roomId, r.AuthorPlayerId, r.Payload, r.Unread))
|
||||
.ToList();
|
||||
|
||||
return Ok(wire);
|
||||
}
|
||||
|
||||
/// <summary>Room-comment unread counts. Client contract (RecNet.Runtime
|
||||
/// <c>OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection<long>)</c>).
|
||||
///
|
||||
/// The response is a JSON <b>ARRAY</b>, not an object. The method's return
|
||||
/// type is <c>Dictionary<long,uint></c>, but that dictionary is built
|
||||
/// client-side: OPELMBNJHNO.txt:889 constructs a
|
||||
/// <c>Func<List<UnreadRoomComments>, Dictionary<long,uint>></c>
|
||||
/// projection that runs over the deserialised payload. So the wire type is
|
||||
/// <c>List<RecNet.UnreadRoomComments></c> — each element an object with a
|
||||
/// room id (Int64) and a count (UInt32), per the two properties on
|
||||
/// RecNet/UnreadRoomComments.txt.
|
||||
///
|
||||
/// This previously returned an object map keyed by room id. Json.NET cannot
|
||||
/// read a JSON object into a <c>List<T></c>, so every call threw and the
|
||||
/// client logged "Failed to get unread room comment counts"
|
||||
/// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's
|
||||
/// room-comment unread badge never appeared.
|
||||
///
|
||||
/// The element's exact property names are attribute-driven and are not
|
||||
/// recoverable from the ISIL (see the note on key aliasing below), so each
|
||||
/// element carries the plausible spellings side by side. Json.NET matches
|
||||
/// case-insensitively and ignores unknown members, so the extra aliases are
|
||||
/// inert whichever name the DTO actually declares.
|
||||
///
|
||||
/// Counts are real now that <see cref="MarkRead"/> persists per-player read
|
||||
/// state: a room's count is the number of comments authored by somebody
|
||||
/// else whose id is above the caller's last-read id for that room.
|
||||
/// </summary>
|
||||
[HttpPost("/comments/unreadcounts")]
|
||||
[HttpGet("/comments/unreadcounts")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> UnreadCounts()
|
||||
{
|
||||
var roomIds = await ReadRoomIdsAsync();
|
||||
if (roomIds.Count == 0) return Ok(new List<Dictionary<string, object>>());
|
||||
|
||||
var me = Me;
|
||||
var lastRead = await LastReadAsync(me);
|
||||
|
||||
// Key + owner only: the payload column is irrelevant to counting.
|
||||
var keys = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(CommentPrefix))
|
||||
.Select(s => new { s.Key, s.PlayerId })
|
||||
.ToListAsync();
|
||||
|
||||
var counts = new Dictionary<long, uint>();
|
||||
foreach (var id in roomIds) counts[id] = 0u;
|
||||
|
||||
foreach (var k in keys)
|
||||
{
|
||||
if (!TrySplitCommentKey(k.Key, out var roomId, out var commentId)) continue;
|
||||
if (!counts.ContainsKey(roomId)) continue;
|
||||
if (k.PlayerId == me) continue; // your own notes are never unread
|
||||
if (commentId <= lastRead.GetValueOrDefault(roomId)) continue;
|
||||
counts[roomId]++;
|
||||
}
|
||||
|
||||
var result = roomIds
|
||||
.Select(id => new Dictionary<string, object>
|
||||
{
|
||||
["RoomId"] = id,
|
||||
["UnreadCount"] = counts[id],
|
||||
["Count"] = counts[id],
|
||||
})
|
||||
.ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Posts a new comment into a room.
|
||||
///
|
||||
/// <c>OPELMBNJHNO.JHABIKEMJHK(Int64 roomId, Int64 subRoomId, String message,
|
||||
/// RoomComment/EKHGDABMJOJ style, Nullable<Vector3> position)</c>
|
||||
/// (OPELMBNJHNO.txt:940) returns <c>FGLDKEJLAKB<RecNet.RoomComment></c> —
|
||||
/// a <b>single</b> comment object, not a list and not a status envelope.
|
||||
///
|
||||
/// Verb is POST (rdx=2 at instr 069), so every <c>AFGEDDANEKP</c> field is
|
||||
/// an <c>application/x-www-form-urlencoded</c> body field, never a query
|
||||
/// param. Field literals, in emission order: "message" (:1234),
|
||||
/// "subRoomId" (:1249, boxed Int64), "style" (:1261, boxed <b>UInt32</b>),
|
||||
/// then — only when the <c>Vector3?</c> has a value (the
|
||||
/// <c>Compare [rdi+12], r12 / JumpIfEqual</c> at :1268) — "positionX"
|
||||
/// (:1292), "positionY" (:1314), "positionZ" (:1336), each formatted with
|
||||
/// <c>Single.ToString(CultureInfo.InvariantCulture)</c> (:1285/1289), which
|
||||
/// is why the three floats are parsed invariantly here rather than left to
|
||||
/// model binding.
|
||||
///
|
||||
/// Failure toast: "Failed to Create Comment" (:1359).</summary>
|
||||
[HttpPost("/comments/create/{roomId:long}")]
|
||||
public async Task<IActionResult> Create(
|
||||
long roomId,
|
||||
[FromForm] string? message,
|
||||
[FromForm] long? subRoomId,
|
||||
[FromForm] int style = 0,
|
||||
[FromForm] string? positionX = null,
|
||||
[FromForm] string? positionY = null,
|
||||
[FromForm] string? positionZ = null)
|
||||
{
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId)) return NotFound();
|
||||
|
||||
message = (message ?? string.Empty).Trim();
|
||||
if (message.Length == 0) return BadRequest();
|
||||
// PlayerSettingEntity.Value is MaxLength(1024) and also carries the
|
||||
// rest of the payload; keep well clear of the column width.
|
||||
if (message.Length > 700) message = message[..700];
|
||||
|
||||
var payload = new CommentPayload
|
||||
{
|
||||
SubRoomId = subRoomId is > 0 ? subRoomId : null,
|
||||
Message = message,
|
||||
Style = style,
|
||||
X = ParseFloat(positionX),
|
||||
Y = ParseFloat(positionY),
|
||||
Z = ParseFloat(positionZ),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
// The client only ever sends all three or none; drop a partial set so
|
||||
// the client's Vector3 reassembly (RoomComment.FKDDCNLJOLF) sees a
|
||||
// consistent null.
|
||||
if (payload.X is null || payload.Y is null || payload.Z is null)
|
||||
payload.X = payload.Y = payload.Z = null;
|
||||
|
||||
var me = Me;
|
||||
var commentId = await NextCommentIdAsync();
|
||||
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = $"{CommentPrefix}{roomId}:{commentId}",
|
||||
Value = JsonSerializer.Serialize(payload),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Authoring a comment implies you have read up to it.
|
||||
await SetLastReadAsync(me, roomId, commentId);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(ToWire(commentId, roomId, me, payload, unread: false));
|
||||
}
|
||||
|
||||
/// <summary>Records the newest comment the caller has read in a room, which
|
||||
/// is what clears the watch's unread badge (and what makes
|
||||
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
|
||||
///
|
||||
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
|
||||
/// (OPELMBNJHNO.txt:1375) returns the status-only <c>LDGADANDBIO</c>, not a
|
||||
/// DTO — the body is never parsed, only the status code. Verb is <b>PUT</b>
|
||||
/// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments
|
||||
/// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so
|
||||
/// the request carries no body at all.
|
||||
///
|
||||
/// Failure toast: "Failed to update latest read comment" (:1656).</summary>
|
||||
[HttpPut("/comments/read/{roomId:long}/{commentId:long}")]
|
||||
public async Task<IActionResult> MarkRead(long roomId, long commentId)
|
||||
{
|
||||
await SetLastReadAsync(Me, roomId, commentId);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Deletes a comment.
|
||||
///
|
||||
/// <c>OPELMBNJHNO.NDLOEEHIDED(Int64 commentId)</c> (OPELMBNJHNO.txt:1781)
|
||||
/// returns the status-only <c>LDGADANDBIO</c>. Verb is <b>DELETE</b>
|
||||
/// (rdx=4 at instr 046) and the host ordinal is the only immediate on the
|
||||
/// builder call (r8=12, RoomComments) — see OPELMBNJHNO.txt:1944-1946.
|
||||
/// No fields are attached: the comment id is the whole request.
|
||||
///
|
||||
/// Because the route carries no room id, the storage key is matched by its
|
||||
/// <c>:{commentId}</c> suffix; read-state rows live under a different
|
||||
/// prefix precisely so they cannot collide here. Only the author, the room
|
||||
/// creator, or an accepted co-owner/moderator of the room may delete —
|
||||
/// anyone else gets 403 and the client raises its "Failed to delete
|
||||
/// comment" toast (:1962).</summary>
|
||||
[HttpDelete("/comments/delete/{commentId:long}")]
|
||||
public async Task<IActionResult> Delete(long commentId)
|
||||
{
|
||||
var suffix = ":" + commentId.ToString(CultureInfo.InvariantCulture);
|
||||
var row = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix))
|
||||
.FirstOrDefaultAsync();
|
||||
if (row is null) return NotFound();
|
||||
if (!TrySplitCommentKey(row.Key, out var roomId, out _)) return NotFound();
|
||||
|
||||
var me = Me;
|
||||
if (row.PlayerId != me && !await CanModerateRoomAsync(roomId, me)) return Forbid();
|
||||
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── Wire shape ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>One <c>RecNet.RoomComment</c>. Member list and CLR types are
|
||||
/// exact (RecNet/RoomComment.cs, cross-checked against the 2023.06.21
|
||||
/// dump.cs:1241576): <c>long</c> comment id, <c>long</c> room id,
|
||||
/// <c>long?</c> sub-room id, <c>int?</c> account id, <c>DateTime</c>
|
||||
/// created-at, <c>string</c> message, the <c>EKHGDABMJOJ</c> style enum, a
|
||||
/// settable <c>bool</c>, three internal <c>float?</c> position components,
|
||||
/// and one computed get-only <c>bool</c> that is never on the wire.
|
||||
///
|
||||
/// KEY NAMES. The 2023 DTOs' JSON names live in DataMember attribute blobs
|
||||
/// in global-metadata.dat and are not recoverable from Cpp2IL output, so
|
||||
/// the names below are taken from the client's own <b>request</b> field
|
||||
/// literals for the same values — "message", "subRoomId", "style",
|
||||
/// "positionX", "positionY", "positionZ" (OPELMBNJHNO.txt:1234-1336) —
|
||||
/// which mirror the server DTO they were serialised from. Json.NET matches
|
||||
/// case-insensitively and ignores unknown members, so the aliases for the
|
||||
/// two members with no request-side counterpart are inert.
|
||||
///
|
||||
/// The settable bool is the unread flag: its only mutator
|
||||
/// <c>RoomComment.MCCJIBCJEJC</c> clears the byte at <c>[rcx+0x64]</c> and
|
||||
/// fires the change event (RoomComment.txt:471-493), and the computed
|
||||
/// get-only bool <c>FOOFBPEMFCB</c> is <c>AccountId == localAccountId ||
|
||||
/// !flag</c> (:442-466, comparing against
|
||||
/// <c>OPMAPIOEIFG.GGAEMNHEHLO</c>). Its declared name is unknown, hence the
|
||||
/// three non-inverting spellings.</summary>
|
||||
private static Dictionary<string, object?> ToWire(
|
||||
long commentId, long roomId, long authorPlayerId, CommentPayload p, bool unread) => new(StringComparer.Ordinal)
|
||||
{
|
||||
["CommentId"] = commentId,
|
||||
["RoomId"] = roomId,
|
||||
["SubRoomId"] = p.SubRoomId,
|
||||
["AccountId"] = (int)authorPlayerId,
|
||||
["CreatedAt"] = p.CreatedAt,
|
||||
["Message"] = p.Message,
|
||||
["Style"] = p.Style,
|
||||
["PositionX"] = p.X,
|
||||
["PositionY"] = p.Y,
|
||||
["PositionZ"] = p.Z,
|
||||
["IsUnread"] = unread,
|
||||
["Unread"] = unread,
|
||||
["IsNew"] = unread,
|
||||
};
|
||||
|
||||
// ── Storage helpers ──────────────────────────────────────────────────
|
||||
|
||||
private sealed record StoredComment(long CommentId, long AuthorPlayerId, CommentPayload Payload, bool Unread);
|
||||
|
||||
/// <summary>Every stored comment for a room, with the caller's unread flag
|
||||
/// resolved against their read-state row.</summary>
|
||||
private async Task<List<StoredComment>> CommentRowsForRoomAsync(long roomId)
|
||||
{
|
||||
var me = Me;
|
||||
var prefix = $"{CommentPrefix}{roomId}:";
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(prefix))
|
||||
.Select(s => new { s.Key, s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
|
||||
var lastRead = await LastReadAsync(me);
|
||||
var seen = lastRead.GetValueOrDefault(roomId);
|
||||
|
||||
var list = new List<StoredComment>(rows.Count);
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (!long.TryParse(r.Key[prefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId))
|
||||
continue;
|
||||
CommentPayload? payload;
|
||||
try { payload = JsonSerializer.Deserialize<CommentPayload>(r.Value); }
|
||||
catch (JsonException) { continue; }
|
||||
if (payload is null) continue;
|
||||
list.Add(new StoredComment(commentId, r.PlayerId, payload, r.PlayerId != me && commentId > seen));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Comment ids are handed out from the wall clock in milliseconds
|
||||
/// so they stay globally unique and monotonically increasing across
|
||||
/// restarts — the client's <c>minId</c> filter and the unread comparison
|
||||
/// both rely on "higher id == newer".</summary>
|
||||
private async Task<long> NextCommentIdAsync()
|
||||
{
|
||||
var id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
for (var attempt = 0; attempt < 64; attempt++)
|
||||
{
|
||||
var suffix = ":" + id.ToString(CultureInfo.InvariantCulture);
|
||||
var taken = await db.PlayerSettings
|
||||
.AnyAsync(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix));
|
||||
if (!taken) break;
|
||||
id++;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Last-read comment id per room for one player.</summary>
|
||||
private async Task<Dictionary<long, long>> LastReadAsync(long playerId)
|
||||
{
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == playerId && s.Key.StartsWith(ReadPrefix))
|
||||
.Select(s => new { s.Key, s.Value })
|
||||
.ToListAsync();
|
||||
|
||||
var map = new Dictionary<long, long>();
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (!long.TryParse(r.Key[ReadPrefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var roomId)) continue;
|
||||
if (!long.TryParse(r.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId)) continue;
|
||||
if (!map.TryGetValue(roomId, out var cur) || commentId > cur) map[roomId] = commentId;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>Read state only ever moves forward — the watch re-sends older
|
||||
/// ids as the grid scrolls back up.</summary>
|
||||
private async Task SetLastReadAsync(long playerId, long roomId, long commentId)
|
||||
{
|
||||
var key = $"{ReadPrefix}{roomId}";
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = key,
|
||||
Value = commentId.ToString(CultureInfo.InvariantCulture),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (long.TryParse(row.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var cur) && cur >= commentId) return;
|
||||
row.Value = commentId.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>Room creator, or an accepted co-owner (Role 0) / moderator
|
||||
/// (Role 1) — the same grant model
|
||||
/// <see cref="Data.Entities.RoomRoleEntity"/> documents.</summary>
|
||||
private async Task<bool> CanModerateRoomAsync(long roomId, long playerId)
|
||||
{
|
||||
if (await db.Rooms.AnyAsync(r => r.Id == roomId && r.CreatorPlayerId == playerId)) return true;
|
||||
return await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && (r.Role == 0 || r.Role == 1));
|
||||
}
|
||||
|
||||
/// <summary>Splits <c>roomcomment:{roomId}:{commentId}</c>.</summary>
|
||||
private static bool TrySplitCommentKey(string key, out long roomId, out long commentId)
|
||||
{
|
||||
roomId = 0;
|
||||
commentId = 0;
|
||||
if (!key.StartsWith(CommentPrefix, StringComparison.Ordinal)) return false;
|
||||
var rest = key[CommentPrefix.Length..];
|
||||
var sep = rest.IndexOf(':');
|
||||
if (sep <= 0) return false;
|
||||
return long.TryParse(rest[..sep], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)
|
||||
&& long.TryParse(rest[(sep + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out commentId);
|
||||
}
|
||||
|
||||
private static float? ParseFloat(string? s) =>
|
||||
float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||
|
||||
private async Task<HashSet<long>> ReadRoomIdsAsync()
|
||||
{
|
||||
var ids = new HashSet<long>();
|
||||
|
||||
void AddCsv(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return;
|
||||
foreach (var part in s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
|
||||
// Query / form: roomIds=1,2,3
|
||||
foreach (var k in new[] { "roomIds", "roomId", "ids", "id" })
|
||||
{
|
||||
AddCsv(Request.Query[k].ToString());
|
||||
if (Request.HasFormContentType) AddCsv(Request.Form[k].ToString());
|
||||
}
|
||||
|
||||
// JSON body: either a bare array [1,2,3] or { "roomIds": [1,2,3] }.
|
||||
if (!Request.HasFormContentType)
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
JsonElement arr = default;
|
||||
var haveArr = false;
|
||||
if (root.ValueKind == JsonValueKind.Array) { arr = root; haveArr = true; }
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var k in new[] { "roomIds", "RoomIds", "ids", "Ids" })
|
||||
if (root.TryGetProperty(k, out arr) && arr.ValueKind == JsonValueKind.Array) { haveArr = true; break; }
|
||||
}
|
||||
if (haveArr)
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
catch { /* non-JSON / empty body */ }
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -65,23 +65,14 @@ public class CommunityBoardController(
|
||||
? new()
|
||||
: await db.Rooms
|
||||
.Where(r => ids.Contains(r.Id))
|
||||
.Select(r => new { r.Id, r.Name, r.ImageName })
|
||||
.ToDictionaryAsync(r => r.Id);
|
||||
// 2020.12 FeaturedRoomGroup deserializer (NMPFCIJPODA.PPGFHEDFBEA)
|
||||
// reads required keys: FeaturedRoomGroupId, Name, Rooms (NOT
|
||||
// FeaturedRooms). Missing/renamed → KeyNotFoundException →
|
||||
// breaks the boot promise chain on the community-board fetch.
|
||||
featuredRoomGroup = new
|
||||
{
|
||||
FeaturedRoomGroupId = 0L,
|
||||
Name = group.Name ?? string.Empty,
|
||||
Rooms = ids.Select(id => rooms.TryGetValue(id, out var r)
|
||||
? new { RoomName = r.Name, RoomId = r.Id, ImageName = RoomService.ResolveDisplayImageName(r) }
|
||||
: new { RoomName = string.Empty, RoomId = id, ImageName = RoomService.DefaultRoomImageName })
|
||||
.ToArray(),
|
||||
// Keep the old key around for any 2020.03 clients hitting prod.
|
||||
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
|
||||
? new { RoomName = r.Name, RoomId = r.Id, ImageName = RoomService.ResolveDisplayImageName(r) }
|
||||
: new { RoomName = string.Empty, RoomId = id, ImageName = RoomService.DefaultRoomImageName })
|
||||
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
|
||||
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
|
||||
.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Compatibility;
|
||||
|
||||
[ApiController]
|
||||
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>The 2023-03-21 client GETs this (verb register <c>rdx=0</c>
|
||||
/// at RecNet.Runtime/FPIBGPIAOBI.txt:2984, immediately before the
|
||||
/// <c>"api/banappeal/generateCode"</c> literal on :2980) and deserialises
|
||||
/// the response as a bare <c>String</c> — the issuing method is
|
||||
/// <c>FGLDKEJLAKB<System.String> HBEPHMAKCDN()</c> (:2759) and the
|
||||
/// continuation is <c>Func<IPCJLCNIBEG<String>,
|
||||
/// FGLDKEJLAKB<String>></c> (:3019).
|
||||
///
|
||||
/// It used to be POST-only returning <c>{value: code}</c>, so the client
|
||||
/// got a 405 and, had it reached the handler, would have thrown parsing a
|
||||
/// JSON object into a string. Ban-appeal code generation never worked.
|
||||
/// The body is now the bare JSON string (<c>"BA-123456"</c>).</summary>
|
||||
[HttpGet("api/banappeal/generateCode")]
|
||||
[HttpPost("api/banappeal/generateCode")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GenerateBanAppealCode()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var code = $"BA-{RandomNumberGenerator.GetInt32(100000, 999999)}";
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"banappeal:{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
Value = code,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Content(JsonSerializer.Serialize(code), "application/json");
|
||||
}
|
||||
|
||||
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> UpdateAndGetCampusCardSubscription()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadCampusCardRequestAsync();
|
||||
var targetId = req.PlatformAccountSubscribedPlayerId > 0
|
||||
? req.PlatformAccountSubscribedPlayerId
|
||||
: pid;
|
||||
|
||||
if (targetId != pid && req.Subscription)
|
||||
{
|
||||
var exists = await db.Subscriptions.AnyAsync(s =>
|
||||
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
|
||||
if (!exists)
|
||||
{
|
||||
db.Subscriptions.Add(new SubscriptionEntity
|
||||
{
|
||||
SubscriberPlayerId = pid,
|
||||
TargetPlayerId = targetId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
else if (targetId != pid && !req.Subscription)
|
||||
{
|
||||
await db.Subscriptions
|
||||
.Where(s => s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
var active = targetId == pid || await db.Subscriptions.AnyAsync(s =>
|
||||
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
|
||||
var now = DateTime.UtcNow;
|
||||
var subscriptionId = unchecked((pid * 397L) ^ targetId);
|
||||
var subscription = new Dictionary<string, object?>
|
||||
{
|
||||
["subscriptionId"] = subscriptionId,
|
||||
["recNetPlayerId"] = pid,
|
||||
["platformAccountSubscribedPlayerId"] = targetId,
|
||||
["isActive"] = active,
|
||||
["startedAt"] = now,
|
||||
["currentPeriodEnd"] = active ? now.AddYears(10) : now,
|
||||
["renewalDate"] = active ? now.AddYears(10) : now,
|
||||
["SubscriptionId"] = subscriptionId,
|
||||
["RecNetPlayerId"] = pid,
|
||||
["PlatformAccountSubscribedPlayerId"] = targetId,
|
||||
["IsActive"] = active,
|
||||
["StartedAt"] = now,
|
||||
["CurrentPeriodEnd"] = active ? now.AddYears(10) : now,
|
||||
["RenewalDate"] = active ? now.AddYears(10) : now,
|
||||
};
|
||||
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["subscription"] = subscription,
|
||||
["platformAccountSubscribedPlayerId"] = targetId,
|
||||
["Subscription"] = subscription,
|
||||
["PlatformAccountSubscribedPlayerId"] = targetId,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
|
||||
=> Content("true", "application/json");
|
||||
|
||||
/// <summary>The 2023-03-21 client POSTs this — the request is built at
|
||||
/// RecNet.Runtime/GAAPALMODOL.txt:104-106, where the route literal
|
||||
/// <c>"api/AppIntegrity/v1/iosproducts"</c> goes into <c>r9</c> and the
|
||||
/// verb register <c>rdx=2</c> = <c>BestHTTP.HTTPMethods.Post</c> (host
|
||||
/// <c>r8=1</c>). The payload is a RAW JSON body — the serialized list is
|
||||
/// handed to <c>BNDIAONDFFF.FJLLPHFOOJJ</c> (:138), not the
|
||||
/// form-parameter helper — holding
|
||||
/// <c>List<GAAPALMODOL/KOLPHINEHDD></c>, i.e. a top-level array of
|
||||
/// {Name:String, Price:Single, ProductId:String} (getters at
|
||||
/// GAAPALMODOL_NestedType_KOLPHINEHDD.txt:3/25/45; formatter key table,
|
||||
/// which also accepts camel/lower spellings, at KAONMCEONPF.txt:255-306).
|
||||
/// The issuing method's return type is
|
||||
/// <c>FGLDKEJLAKB<PHMHCPEMABG></c> (:3) and PHMHCPEMABG is
|
||||
/// {Boolean, String} (PHMHCPEMABG.txt:3/23) keyed
|
||||
/// <c>Success</c>/<c>Message</c> (GBPDOLJBABB.txt:191-218).
|
||||
///
|
||||
/// <see cref="DorkNet.Server.Controllers.API.AppIntegrity.AppIntegrityController"/>
|
||||
/// registers the same
|
||||
/// path GET-only, so the client's POST hit a 405 and the StoreKit
|
||||
/// price report was dropped. Registering only the POST verb here leaves
|
||||
/// that GET (and its consumers) untouched — the two never collide
|
||||
/// because the method constraints are disjoint.
|
||||
///
|
||||
/// The reported catalogue is what Apple's StoreKit resolved on the
|
||||
/// device, so it is inherently per-account/per-storefront: it is
|
||||
/// persisted as the reporting player's <c>appintegrity:iosproducts</c>
|
||||
/// setting (same PlayerSettings convention as the ban-appeal handler
|
||||
/// above), replacing any earlier report. Only a malformed body answers
|
||||
/// Success=false; the client shows nothing on success and PHMHCPEMABG's
|
||||
/// Message is left empty.</summary>
|
||||
[HttpPost("api/AppIntegrity/v1/iosproducts")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IosProducts()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
|
||||
List<IosProduct>? products;
|
||||
try
|
||||
{
|
||||
products = await JsonSerializer.DeserializeAsync<List<IosProduct>>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return IosProductsResult(false, "malformed_product_list");
|
||||
}
|
||||
|
||||
if (products is null) return IosProductsResult(false, "malformed_product_list");
|
||||
|
||||
// "{productId}={price}" pairs rather than the raw JSON: the
|
||||
// localized Name adds nothing a server-side reader needs and
|
||||
// PlayerSettingEntity.Value caps at 1024 chars, so entries are
|
||||
// dropped (never truncated mid-pair) once the budget is spent.
|
||||
var parts = new List<string>();
|
||||
var length = 0;
|
||||
foreach (var p in products)
|
||||
{
|
||||
var productId = p.ProductId?.Trim();
|
||||
if (string.IsNullOrEmpty(productId)) continue;
|
||||
var pair = $"{productId}={p.Price.ToString(CultureInfo.InvariantCulture)}";
|
||||
var cost = parts.Count == 0 ? pair.Length : pair.Length + 1;
|
||||
if (length + cost > 1024) break;
|
||||
parts.Add(pair);
|
||||
length += cost;
|
||||
}
|
||||
|
||||
const string key = "appintegrity:iosproducts";
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = key,
|
||||
Value = string.Join(';', parts),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = string.Join(';', parts);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return IosProductsResult(true, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>PHMHCPEMABG accepts PascalCase, camelCase and all-lowercase
|
||||
/// key spellings (GBPDOLJBABB.txt:191-218), so either casing alone would
|
||||
/// satisfy it; both are emitted for the same reason the CampusCard
|
||||
/// payload above does. Dictionary keys go out verbatim — the global
|
||||
/// serializer sets PropertyNamingPolicy = null
|
||||
/// (Startup/ServiceCollectionExtensions.cs:381).</summary>
|
||||
private IActionResult IosProductsResult(bool success, string message)
|
||||
=> Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["Success"] = success,
|
||||
["Message"] = message,
|
||||
["success"] = success,
|
||||
["message"] = message,
|
||||
});
|
||||
|
||||
[HttpPost("api/clubreporting/v1/report")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ClubReport()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadClubReportAsync();
|
||||
var club = req.ClubId > 0
|
||||
? await db.Clubs.FirstOrDefaultAsync(c => c.Id == req.ClubId)
|
||||
: null;
|
||||
if (club is null) return Ok(new { Success = false, Message = "club_not_found" });
|
||||
|
||||
// Prefix with "[club {id}]" so admins can tell club reports apart
|
||||
// from player reports in the shared ReportEntity queue.
|
||||
var message = $"[club {club.Id}] {req.Details ?? req.Message ?? string.Empty}".Trim();
|
||||
if (message.Length > 1000) message = message[..1000];
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = pid,
|
||||
TargetPlayerId = club.CreatorPlayerId,
|
||||
Category = req.ReportCategory,
|
||||
Message = message,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
private async Task<CampusCardRequest> ReadCampusCardRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new CampusCardRequest
|
||||
{
|
||||
Subscription = bool.TryParse(form["subscription"].FirstOrDefault(), out var s) && s,
|
||||
PlatformAccountSubscribedPlayerId = long.TryParse(
|
||||
form["platformAccountSubscribedPlayerId"].FirstOrDefault(), out var id) ? id : 0,
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<CampusCardRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new CampusCardRequest();
|
||||
}
|
||||
catch (JsonException) { return new CampusCardRequest(); }
|
||||
}
|
||||
|
||||
private async Task<ClubReportRequest> ReadClubReportAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new ClubReportRequest
|
||||
{
|
||||
ClubId = long.TryParse(form["clubId"].FirstOrDefault()
|
||||
?? form["ClubId"].FirstOrDefault(), out var clubId) ? clubId : 0,
|
||||
ReportCategory = int.TryParse(form["reportCategory"].FirstOrDefault()
|
||||
?? form["ReportCategory"].FirstOrDefault(), out var cat) ? cat : 5,
|
||||
// The 2023 client names the free-text field "details"
|
||||
// (IKMMOCKDKAF.txt:25303-25322), not "message" — reading only
|
||||
// the latter stored every club report with an empty body.
|
||||
Message = form["details"].FirstOrDefault()
|
||||
?? form["Details"].FirstOrDefault()
|
||||
?? form["message"].FirstOrDefault()
|
||||
?? form["Message"].FirstOrDefault(),
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<ClubReportRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new ClubReportRequest();
|
||||
}
|
||||
catch (JsonException) { return new ClubReportRequest(); }
|
||||
}
|
||||
|
||||
private sealed class CampusCardRequest
|
||||
{
|
||||
public bool Subscription { get; set; }
|
||||
public long PlatformAccountSubscribedPlayerId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One entry of the client's raw JSON array —
|
||||
/// GAAPALMODOL/KOLPHINEHDD, whose three properties are String, Single,
|
||||
/// String in that field order (offsets 0x10/0x18/0x20 in
|
||||
/// GAAPALMODOL_NestedType_KOLPHINEHDD.txt) and whose formatter writes
|
||||
/// the keys Name / Price / ProductId (KAONMCEONPF.txt:321-360).</summary>
|
||||
private sealed class IosProduct
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public float Price { get; set; }
|
||||
public string? ProductId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ClubReportRequest
|
||||
{
|
||||
public long ClubId { get; set; }
|
||||
public int ReportCategory { get; set; } = 5;
|
||||
public string? Message { get; set; }
|
||||
/// <summary>The client's spelling of <see cref="Message"/>.</summary>
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -21,130 +21,6 @@ public class ConfigController(ConfigService configService) : ControllerBase
|
||||
[HttpGet("amplitude")]
|
||||
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>api/config/v1/backtrace</c> — 2023 boot downloads this before
|
||||
/// audio/core initialization. Returning 404 leaves the static backtrace
|
||||
/// config null and later crashes <c>ConfigSettings</c> consumers. Keep
|
||||
/// crash upload/deobfuscation disabled locally while preserving the
|
||||
/// object shape and readable config keys the client inspects.
|
||||
/// </summary>
|
||||
[HttpGet("backtrace")]
|
||||
public ActionResult<object> GetBacktrace(
|
||||
[FromQuery(Name = "platformType")] string? platformType,
|
||||
[FromQuery(Name = "allocate")] bool? allocate)
|
||||
{
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
var submissionUrl = $"{baseUrl}/backtrace/submit";
|
||||
var minidumpSubmissionUrl = $"{baseUrl}/backtrace/minidump";
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["Backtrace.SampleRate"] = 0.0,
|
||||
["Backtrace.ReportBudget"] = 0,
|
||||
["Backtrace.deobfuscationRate"] = 0.0,
|
||||
["Backtrace.Deobfuscate"] = false,
|
||||
["Backtrace.DeobfuscationUrl"] = string.Empty,
|
||||
["Backtrace.Enabled"] = false,
|
||||
["Backtrace.PlatformType"] = platformType ?? string.Empty,
|
||||
["Backtrace.Allocate"] = allocate ?? false,
|
||||
};
|
||||
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["MaxMessagesPerMinute"] = 0,
|
||||
["MaxReportsPerMinute"] = 0,
|
||||
["SampleRate"] = 0.0,
|
||||
["ReportBudget"] = 0,
|
||||
["ErrorReportBudget"] = 0,
|
||||
["WarningReportBudget"] = 0,
|
||||
["LogReportBudget"] = 0,
|
||||
["SubmissionUrl"] = submissionUrl,
|
||||
["MinidumpSubmissionUrl"] = minidumpSubmissionUrl,
|
||||
["ServerUrl"] = submissionUrl,
|
||||
["DatabasePath"] = "backtrace/database",
|
||||
["Config"] = config,
|
||||
["maxMessagesPerMinute"] = 0,
|
||||
["maxReportsPerMinute"] = 0,
|
||||
["sampleRate"] = 0.0,
|
||||
["reportBudget"] = 0,
|
||||
["errorReportBudget"] = 0,
|
||||
["warningReportBudget"] = 0,
|
||||
["logReportBudget"] = 0,
|
||||
["submissionUrl"] = submissionUrl,
|
||||
["minidumpSubmissionUrl"] = minidumpSubmissionUrl,
|
||||
["serverUrl"] = submissionUrl,
|
||||
["databasePath"] = "backtrace/database",
|
||||
["config"] = config,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("azurespeech")]
|
||||
public IActionResult GetAzureSpeech()
|
||||
{
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["Enabled"] = false,
|
||||
["SpeechRecognitionEnabled"] = false,
|
||||
["SpeechSynthesisEnabled"] = false,
|
||||
["Region"] = string.Empty,
|
||||
["SubscriptionKey"] = string.Empty,
|
||||
["TokenEndpoint"] = string.Empty,
|
||||
["SpeechRecognitionEndpoint"] = string.Empty,
|
||||
["SpeechSynthesisEndpoint"] = string.Empty,
|
||||
["enabled"] = false,
|
||||
["speechRecognitionEnabled"] = false,
|
||||
["speechSynthesisEnabled"] = false,
|
||||
["region"] = string.Empty,
|
||||
["subscriptionKey"] = string.Empty,
|
||||
["tokenEndpoint"] = string.Empty,
|
||||
["speechRecognitionEndpoint"] = string.Empty,
|
||||
["speechSynthesisEndpoint"] = string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/voice/config")]
|
||||
public IActionResult GetVoiceConfig()
|
||||
{
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["ToxMod.Enabled"] = false,
|
||||
["ToxMod.SampleRate"] = 0.0,
|
||||
["ToxMod.SessionRecordingEnabled"] = false,
|
||||
["ToxMod.Endpoint"] = string.Empty,
|
||||
["ToxMod.ApiKey"] = string.Empty,
|
||||
["Enabled"] = false,
|
||||
["enabled"] = false,
|
||||
["toxModEnabled"] = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/voice/requiresModeration</c> — bare Boolean the
|
||||
/// client reads to decide whether voice needs moderation. ToxMod is
|
||||
/// disabled server-side, so no moderation is required.</summary>
|
||||
[HttpGet("/voice/requiresModeration")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult VoiceRequiresModeration() => Content("false", "application/json");
|
||||
|
||||
/// <summary>GET <c>/emojiConfig/whitelistedEmojis</c> — the allow-list of
|
||||
/// emoji permitted in text chat (RRUI.EmojiPicker, IReadOnlyList<string>).
|
||||
/// Returns a broad default set so the picker is populated.</summary>
|
||||
[HttpGet("/emojiConfig/whitelistedEmojis")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult WhitelistedEmojis() => Ok(new[]
|
||||
{
|
||||
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
|
||||
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
|
||||
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
|
||||
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
|
||||
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
|
||||
/// 2020.12 client deserializes this endpoint as a raw Boolean.
|
||||
/// This server does not run the timed free-gift campaign, so the
|
||||
/// button is hidden.</summary>
|
||||
[HttpGet("freegiftbutton")]
|
||||
public IActionResult FreeGiftButton() => Content("false", "application/json");
|
||||
|
||||
/// <summary>
|
||||
/// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four
|
||||
/// "What would you like to do first?" buttons shown in the OOBE
|
||||
|
||||
@@ -5,7 +5,6 @@ using DorkNet.Server.Services;
|
||||
namespace DorkNet.Server.Controllers.API.Config.V2;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/config")]
|
||||
[Route("api/[controller]/v2")]
|
||||
public class ConfigController(ConfigService configService) : ControllerBase
|
||||
{
|
||||
|
||||
@@ -1,1229 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/customAvatarItems")]
|
||||
[Route("api/customAvatarItems/v1")]
|
||||
[Route("api/customAvatarItems/v2")]
|
||||
public class CustomAvatarItemsController(
|
||||
DorkNetDbContext db,
|
||||
LevelService level,
|
||||
DomainConfig domain,
|
||||
IObjectStorage storage,
|
||||
ILogger<CustomAvatarItemsController> logger) : ControllerBase
|
||||
{
|
||||
/// <summary>Floor price for a publicly listed custom item. The client
|
||||
/// asks for this over the wire (<c>api/customAvatarItems/v1/minPriceForPublicItem</c>,
|
||||
/// RecNet.Runtime/MPBLNLMCEDL.txt:6893) and then enforces it locally in the
|
||||
/// designer, so the number the create/update paths clamp to and the number
|
||||
/// served on that route have to be the same constant or the client's own
|
||||
/// validation disagrees with the server's rejection.</summary>
|
||||
private const int MinPublicItemPrice = 100;
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(0, skip);
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
IQueryable<CustomAvatarItemEntity> rows = db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
rows = rows.Where(i =>
|
||||
i.Name.ToLower().Contains(needle) ||
|
||||
i.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var result = await rows
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(result.Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>POST api/customAvatarItems/v1 — publish a shirt from the
|
||||
/// in-game designer. The collection route was GET-only, so every
|
||||
/// create 405'd and the designer's "publish" button could never work.
|
||||
///
|
||||
/// The 2023-03-21 client sends <c>multipart/form-data</c> (verb ordinal
|
||||
/// rdx=2=POST at RecNet.Runtime/MPBLNLMCEDL.txt:1352-1354): a named
|
||||
/// param <c>"metadata"</c> carrying JsonConvert.SerializeObject of the
|
||||
/// DCKDCBDHDKI request object, then TWO file parts, <c>"thumbnailImage"</c>
|
||||
/// and <c>"design"</c>, both with filename <c>"file.bin"</c>
|
||||
/// (:1362-1390). DCKDCBDHDKI's field order is pinned by the arg-store
|
||||
/// sequence at :1096-1133 — +0x10 Name, +0x18 Description, +0x20 Price,
|
||||
/// +0x24 BaseAvatarItemId(int?), +0x30 Color ("#RRGGBB", built via
|
||||
/// ColorUtility.ToHtmlStringRGB at :1118-1131), +0x38 the permission
|
||||
/// enum we store as ItemType. The metadata JSON KEY names live in
|
||||
/// attribute metadata the dump doesn't render, so every field is read
|
||||
/// through an alias list rather than a single guessed key.</summary>
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
[Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadDesignRequestAsync();
|
||||
|
||||
var name = (req.Name ?? string.Empty).Trim();
|
||||
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
|
||||
if (name.Length > 128) name = name[..128];
|
||||
var description = (req.Description ?? string.Empty).Trim();
|
||||
if (description.Length > 1024) description = description[..1024];
|
||||
|
||||
var item = new CustomAvatarItemEntity
|
||||
{
|
||||
CreatorPlayerId = pid,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Price = Math.Max(MinPublicItemPrice, req.Price ?? MinPublicItemPrice),
|
||||
ItemType = req.ItemType ?? 0,
|
||||
BaseAvatarItemId = req.BaseAvatarItemId ?? 0,
|
||||
Color = Trim(req.Color, 64),
|
||||
// No boolean lives in DCKDCBDHDKI, so a freshly created item is
|
||||
// private until the permission enum (ItemType) says otherwise.
|
||||
IsPublic = req.IsPublic ?? false,
|
||||
};
|
||||
|
||||
// "thumbnailImage" → ImageName (card art), "design" → AssetName (the
|
||||
// shirt texture itself). Fall back to positional order for any client
|
||||
// that names the parts differently.
|
||||
var (thumbnail, asset) = ReadItemFileParts();
|
||||
if (thumbnail is not null)
|
||||
item.ImageName = await StoreImageBlobAsync(pid, thumbnail, "custom item thumbnail");
|
||||
if (asset is not null)
|
||||
item.AssetName = await StoreImageBlobAsync(pid, asset, "custom item design");
|
||||
if (item.ImageName.Length == 0) item.ImageName = Trim(req.ImageName, 256);
|
||||
if (item.AssetName.Length == 0) item.AssetName = Trim(req.AssetName, 256);
|
||||
|
||||
db.CustomAvatarItems.Add(item);
|
||||
await db.SaveChangesAsync();
|
||||
await EnsureOwnershipAsync(pid, item.Id);
|
||||
|
||||
logger.LogInformation(
|
||||
"[customAvatarItems] created {PublicId} by {Pid} name='{Name}' image={Image} asset={Asset}",
|
||||
item.PublicId, pid, item.Name, item.ImageName, item.AssetName);
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
||||
if (item is null) return NotFound();
|
||||
if (!item.IsPublic && item.CreatorPlayerId != this.CurrentPlayerId()) return Forbid();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
/// <summary>PUT api/customAvatarItems/v1/{id} — edit an existing item's
|
||||
/// metadata. Only GET was registered on the id route, so the details
|
||||
/// screen's Save button 405'd ("Unable to update custom avatar item").
|
||||
///
|
||||
/// The client builds the URL with String.Format("{0}/v1/{1}") over
|
||||
/// "api/customAvatarItems" and issues verb ordinal 3 = PUT
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:1899-1913). The body is RAW JSON —
|
||||
/// JsonConvert.SerializeObject of MOBHOFPIIIJ handed to
|
||||
/// BNDIAONDFFF.FJLLPHFOOJJ at :1890-1920 — NOT form-urlencoded, so this
|
||||
/// reads the body itself rather than model-binding it. MOBHOFPIIIJ
|
||||
/// carries exactly four properties whose accessors are shared with
|
||||
/// NOAAFNFJPFB (MOBHOFPIIIJ.txt:3-77): Name, Description, Price(int?)
|
||||
/// and the permission enum stored here as ItemType. Every field is
|
||||
/// optional — absent members leave the stored value alone.</summary>
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Update(Guid id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
||||
if (item is null) return NotFound();
|
||||
if (item.CreatorPlayerId != pid) return Forbid();
|
||||
|
||||
var req = await ReadDesignRequestAsync();
|
||||
if (req.Name is { } rawName)
|
||||
{
|
||||
var name = rawName.Trim();
|
||||
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
|
||||
item.Name = name.Length > 128 ? name[..128] : name;
|
||||
}
|
||||
if (req.Description is { } rawDescription)
|
||||
{
|
||||
var description = rawDescription.Trim();
|
||||
item.Description = description.Length > 1024 ? description[..1024] : description;
|
||||
}
|
||||
if (req.Price is { } price) item.Price = Math.Max(MinPublicItemPrice, price);
|
||||
if (req.ItemType is { } itemType) item.ItemType = itemType;
|
||||
if (req.BaseAvatarItemId is { } baseItemId) item.BaseAvatarItemId = baseItemId;
|
||||
if (req.Color is { } color) item.Color = Trim(color, 64);
|
||||
if (req.ImageName is { } imageName) item.ImageName = Trim(imageName, 256);
|
||||
if (req.AssetName is { } assetName) item.AssetName = Trim(assetName, 256);
|
||||
if (req.IsPublic is { } isPublic) item.IsPublic = isPublic;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
/// <summary>DELETE api/customAvatarItems/v1/{id} — verb ordinal 4 on the
|
||||
/// same String.Format("{0}/v1/{1}") URL
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:2188-2199); the client is
|
||||
/// fire-and-forget, so the missing route made deleted shirts silently
|
||||
/// reappear on the next list refresh.
|
||||
///
|
||||
/// <see cref="CustomAvatarItemEntity"/> has no IsDeleted column (unlike
|
||||
/// UgcPurchasableEntity), and adding one is a schema migration, so this
|
||||
/// is a real delete: the ownership rows go first so nobody keeps the
|
||||
/// item in their wardrobe, then the item row itself.</summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
||||
if (item is null) return Ok();
|
||||
if (item.CreatorPlayerId != pid) return Forbid();
|
||||
|
||||
var ownership = await db.CustomAvatarItemOwnership
|
||||
.Where(o => o.CustomAvatarItemId == item.Id)
|
||||
.ToListAsync();
|
||||
if (ownership.Count > 0) db.CustomAvatarItemOwnership.RemoveRange(ownership);
|
||||
db.CustomAvatarItems.Remove(item);
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"[customAvatarItems] deleted {PublicId} by {Pid} ({Owners} ownership rows removed)",
|
||||
id, pid, ownership.Count);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>GET/POST api/customAvatarItems/v1/bulk — resolve the custom
|
||||
/// items other players are wearing. The guid-list parameter key the
|
||||
/// client uses is the literal <c>"customAvatarItemIds"</c>
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:232-244, POST
|
||||
/// ordinal rdx=2), which no read path here honoured, and the old
|
||||
/// <c>[FromBody] BulkRequest</c> parameter made ASP.NET reject a
|
||||
/// form-urlencoded body with 415 before the handler ran. Result: an
|
||||
/// empty array, remote players' shirts never rendered, and the client
|
||||
/// re-queried the same ids forever — the same failure the ugcPurchasables
|
||||
/// bulk endpoint hit. The binding is gone; <see cref="ReadIdsAsync"/>
|
||||
/// now reads the key from the JSON body, the form and the query.</summary>
|
||||
[HttpGet("bulk")]
|
||||
[HttpPost("bulk")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
var ids = await ReadIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("featured")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Featured([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic && i.IsFeatured)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("hot")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Hot([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic)
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.ThenByDescending(i => i.PurchaseCount)
|
||||
.ThenByDescending(i => i.UpdatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("new")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> New([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic)
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("fromCreator/{creatorId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> FromCreator(
|
||||
long creatorId,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 100)
|
||||
{
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var currentPlayerId = this.CurrentPlayerId();
|
||||
var query = db.CustomAvatarItems
|
||||
.Where(i => i.CreatorPlayerId == creatorId
|
||||
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId));
|
||||
var total = await query.CountAsync();
|
||||
var rows = await query
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
// Paged container, not a bare array — the client deserializes this into
|
||||
// CKFLFJCNEPH {Results, TotalResults}, same as Owned() below.
|
||||
return Ok(new { Results = rows.Select(ToWire), TotalResults = total });
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var createdQuery = db.CustomAvatarItems
|
||||
.Where(i => i.CreatorPlayerId == pid)
|
||||
.OrderByDescending(i => i.UpdatedAt);
|
||||
var total = await createdQuery.CountAsync();
|
||||
var created = await createdQuery
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
var ownedIds = await db.CustomAvatarItemOwnership
|
||||
.Where(o => o.PlayerId == pid)
|
||||
.Select(o => o.CustomAvatarItemId)
|
||||
.ToListAsync();
|
||||
var owned = ownedIds.Count == 0
|
||||
? new List<CustomAvatarItemEntity>()
|
||||
: await db.CustomAvatarItems
|
||||
.Where(i => ownedIds.Contains(i.Id))
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Results = created.Select(ToWire),
|
||||
TotalResults = total,
|
||||
Created = created.Select(ToWire),
|
||||
Owned = owned.Select(ToWire),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("owned")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Owned([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var query = db.CustomAvatarItemOwnership
|
||||
.Where(o => o.PlayerId == pid)
|
||||
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
|
||||
.OrderByDescending(i => i.UpdatedAt);
|
||||
var total = await query.CountAsync();
|
||||
var rows = await query
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Results = rows.Select(ToWire),
|
||||
TotalResults = total,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("isCreationAllowedForAccount")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IsCreationAllowedForAccount()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var player = await db.Players
|
||||
.Where(p => p.Id == pid)
|
||||
.Select(p => new { p.IsJunior, p.BannedUntil })
|
||||
.FirstOrDefaultAsync();
|
||||
var allowed = player is not null
|
||||
&& !player.IsJunior
|
||||
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
||||
// Bare JSON boolean, like the two sibling gates below: the client's
|
||||
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK()
|
||||
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an
|
||||
// object body threw and the can-create gate blocked shirt creation.
|
||||
return Content(allowed ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("isCreationEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsCreationEnabled() => Content("true", "application/json");
|
||||
|
||||
[HttpGet("isRenderingEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
||||
|
||||
/// <summary>The client DTO (IHLMDLKOLNB) is an object with exactly one
|
||||
/// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere
|
||||
/// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are
|
||||
/// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT
|
||||
/// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft
|
||||
/// ignores the ones that don't match, and a total miss falls back to the
|
||||
/// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing.</summary>
|
||||
[HttpGet("minPriceForPublicItem")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult MinPriceForPublicItem() => Ok(new
|
||||
{
|
||||
MinPrice = MinPublicItemPrice,
|
||||
Price = MinPublicItemPrice,
|
||||
MinimumPrice = MinPublicItemPrice,
|
||||
MinPriceForPublicItem = MinPublicItemPrice,
|
||||
});
|
||||
|
||||
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
|
||||
/// saved in-progress shirt design. Verb ordinal 0 = GET
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4782-4783); only POST was registered,
|
||||
/// so the designer logged "Unable to get custom avatar design info" every
|
||||
/// time it opened.
|
||||
///
|
||||
/// The response DTO NCMMAGDIBND is {Int32 @0x10, Int32? @0x14, String
|
||||
/// @0x20, String @0x28} (NCMMAGDIBND.txt:3-145). The obfuscator's name
|
||||
/// map is per-property-name rather than per-member, so those four
|
||||
/// accessors identify the properties: IDELLAGIFAE/MOKDHHGDMJB is the same
|
||||
/// pair used for BaseAvatarItemId on GIOBJIGEMNG and DCKDCBDHDKI,
|
||||
/// GJEFKPNEFGI is the Color set from ColorUtility.ToHtmlStringRGB, and
|
||||
/// DPJMMBLGKGF / BADCCGPNLMN are the leading int and the first string of
|
||||
/// NOAAFNFJPFB — the creator id and the image name. Hence
|
||||
/// {CreatorPlayerId, BaseAvatarItemId, ImageName, Color}. The extra keys
|
||||
/// are inert aliases.
|
||||
///
|
||||
/// Returns an empty design rather than 404 when nothing is saved so the
|
||||
/// designer opens clean instead of showing the error toast.</summary>
|
||||
[HttpGet("design")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetDesign()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var design = await LoadDesignAsync(pid) ?? new StoredDesign();
|
||||
return Ok(new
|
||||
{
|
||||
CreatorPlayerId = (int)pid,
|
||||
CreatorId = (int)pid,
|
||||
design.BaseAvatarItemId,
|
||||
design.ImageName,
|
||||
ImageUrl = string.IsNullOrWhiteSpace(design.ImageName)
|
||||
? string.Empty
|
||||
: $"https://{domain.Sub("cdn")}/{design.ImageName}",
|
||||
design.Color,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>PUT api/customAvatarItems/v1/design — save the in-progress
|
||||
/// shirt texture. The route only accepted POST, so the designer could
|
||||
/// never persist a design ("Unable to save custom avatar design").
|
||||
///
|
||||
/// Verb ordinal 3 = PUT with a multipart body: named param
|
||||
/// <c>"metadata"</c> plus a file part <c>"design"</c> with filename
|
||||
/// <c>"file.bin"</c>
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:200-227). The
|
||||
/// metadata object GIOBJIGEMNG holds just two properties — a
|
||||
/// Nullable<Int32> base avatar item id at +0x10 and the "#RRGGBB"
|
||||
/// colour string at +0x18 (GIOBJIGEMNG.txt:3-43), written from the
|
||||
/// FKCJDHHJAFF(texture, color, baseItemId) args at MPBLNLMCEDL.txt:4652-4654.
|
||||
/// The client's return type is status-only (LDGADANDBIO), so a bare 200
|
||||
/// is the whole contract.</summary>
|
||||
[HttpPut("design")]
|
||||
[Authorize]
|
||||
[Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
|
||||
public async Task<IActionResult> SaveDesign()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadDesignRequestAsync();
|
||||
var existing = await LoadDesignAsync(pid);
|
||||
|
||||
var design = new StoredDesign
|
||||
{
|
||||
BaseAvatarItemId = req.BaseAvatarItemId ?? existing?.BaseAvatarItemId,
|
||||
Color = Trim(req.Color, 64) is { Length: > 0 } color ? color : existing?.Color ?? string.Empty,
|
||||
ImageName = existing?.ImageName ?? string.Empty,
|
||||
};
|
||||
|
||||
// The texture arrives as the "design" part; accept any single file
|
||||
// part as a fallback for a client that names it differently.
|
||||
var file = Request.HasFormContentType
|
||||
? Request.Form.Files.GetFile("design") ?? Request.Form.Files.FirstOrDefault()
|
||||
: null;
|
||||
if (file is not null && file.Length > 0)
|
||||
{
|
||||
var bytes = await ReadFileBytesAsync(file);
|
||||
var blobName = DesignImageBlobName(pid);
|
||||
await PutBlobAsync(blobName, bytes, SniffImageContentType(bytes), "custom avatar design");
|
||||
design.ImageName = blobName;
|
||||
}
|
||||
|
||||
await SaveDesignAsync(pid, design);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>DELETE api/customAvatarItems/v1/design — discard the saved
|
||||
/// design. Verb ordinal 4 on the same literal
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4862-4864); without it the client
|
||||
/// logged "Unable to delete custom avatar item design".
|
||||
///
|
||||
/// <see cref="IObjectStorage"/> has no delete operation, so the record is
|
||||
/// tombstoned (a Deleted marker that <see cref="LoadDesignAsync"/> reads
|
||||
/// as "no design") and the texture object is truncated to zero bytes so
|
||||
/// the pixels really are gone.</summary>
|
||||
[HttpDelete("design")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteDesign()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
await SaveDesignAsync(pid, new StoredDesign { Deleted = true });
|
||||
await PutBlobAsync(DesignImageBlobName(pid), Array.Empty<byte>(), "image/png", "custom avatar design");
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("design")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Design()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadDesignRequestAsync();
|
||||
var name = (req.Name ?? string.Empty).Trim();
|
||||
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
|
||||
if (name.Length > 128) name = name[..128];
|
||||
|
||||
var description = (req.Description ?? string.Empty).Trim();
|
||||
if (description.Length > 1024) description = description[..1024];
|
||||
|
||||
CustomAvatarItemEntity item;
|
||||
if (req.Id is Guid id)
|
||||
{
|
||||
item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id)
|
||||
?? new CustomAvatarItemEntity { PublicId = id, CreatorPlayerId = pid };
|
||||
if (item.Id != 0 && item.CreatorPlayerId != pid) return Forbid();
|
||||
if (item.Id == 0) db.CustomAvatarItems.Add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
item = new CustomAvatarItemEntity { CreatorPlayerId = pid };
|
||||
db.CustomAvatarItems.Add(item);
|
||||
}
|
||||
|
||||
item.Name = name;
|
||||
item.Description = description;
|
||||
item.Price = Math.Max(MinPublicItemPrice, req.Price ?? item.Price);
|
||||
item.ItemType = req.ItemType ?? item.ItemType;
|
||||
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
|
||||
item.Color = Trim(req.Color, 64);
|
||||
item.ImageName = Trim(req.ImageName, 256);
|
||||
item.AssetName = Trim(req.AssetName, 256);
|
||||
item.IsPublic = req.IsPublic ?? item.IsPublic;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await EnsureOwnershipAsync(pid, item.Id);
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client buys custom items through the
|
||||
/// <c>econ/</c> host prefix — <c>econ/customAvatarItems/v1/{id}/purchase</c>
|
||||
/// — which was never registered, so every custom-shirt purchase 404'd. The
|
||||
/// rooted route below is an alias onto the same handler. <c>requestedPrice</c>
|
||||
/// arrives as a query param and is validated against the item's real price
|
||||
/// so a tampered client can't buy at its own number.</summary>
|
||||
[HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")]
|
||||
[HttpPost("{id:guid}/purchase")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Purchase(Guid id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id && i.IsPublic);
|
||||
if (item is null) return NotFound();
|
||||
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
|
||||
return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) });
|
||||
|
||||
// requestedPrice is baked into the URL the client formats —
|
||||
// "{0}/v1/{1}/purchase/?requestedPrice={2}"
|
||||
// (RecNet.Runtime/MPBLNLMCEDL.txt:5084). It is the price the player was
|
||||
// SHOWN, so a value below the real price means the listing moved under
|
||||
// them (or the request was tampered with); reject instead of silently
|
||||
// charging more than the confirmation dialog said.
|
||||
var requestedPrice = int.TryParse(Request.Query["requestedPrice"].FirstOrDefault(), out var rp) ? rp : (int?)null;
|
||||
if (requestedPrice is { } shown && shown < item.Price)
|
||||
return Ok(new { Success = false, Error = "price_changed", Price = item.Price, Item = ToWire(item) });
|
||||
|
||||
var balance = await level.GetBalanceAsync(pid, 2);
|
||||
if (balance < item.Price)
|
||||
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
|
||||
|
||||
var newBalance = await level.GrantCurrencyAsync(pid, 2, -item.Price, $"customAvatarItem:{item.PublicId}");
|
||||
await EnsureOwnershipAsync(pid, item.Id);
|
||||
item.PurchaseCount += 1;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) });
|
||||
}
|
||||
|
||||
/// <summary>POST api/customAvatarItems/v1/{id}/report — file a moderation
|
||||
/// report against another player's custom shirt. No report route was
|
||||
/// registered anywhere on the server, so the report dialog's "Submit"
|
||||
/// button 404'd and the client toasted "Unable to report custom avatar
|
||||
/// item {0}".
|
||||
///
|
||||
/// Verb ordinal <c>dl=2</c> = POST, on the URL
|
||||
/// String.Format("{0}/v1/{1}/report") over "api/customAvatarItems"
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4261-4275; the failure literal is
|
||||
/// :4301). The body is RAW JSON — JsonConvert.SerializeObject of a
|
||||
/// JEHCPEEKMML handed straight to BNDIAONDFFF.FJLLPHFOOJJ (:4252-4256,
|
||||
/// :4282), NOT form-urlencoded — so the body is read here instead of
|
||||
/// model-bound, exactly like PUT {id} above.
|
||||
///
|
||||
/// The issuing method IALGIAGBJMA (:4079) returns LDGADANDBIO — status
|
||||
/// only. Its continuation chain (0x181F2F300 → 0x1836E6740 →
|
||||
/// BFDGEKKNAPB.PEFKBAEAOPD, :4285-4294) is byte-for-byte the one the
|
||||
/// DELETE {id} path uses (:2206-2215), and that path is satisfied by a
|
||||
/// bare 200, so an empty 200 is the whole response contract here too.
|
||||
///
|
||||
/// JEHCPEEKMML has exactly three members, pinned by field offset from the
|
||||
/// accessor bodies (JEHCPEEKMML.txt:6/86/108) and by the arg-store
|
||||
/// sequence that fills it (:4239-4247): +0x10 the reason enum, +0x18 the
|
||||
/// free-text detail, +0x20 a Nullable<Int32>. Their JSON KEY names
|
||||
/// live in attribute metadata Cpp2IL doesn't render — obfuscated in the
|
||||
/// 2023.06 dump too (EFPKFBKGLCD, dump.cs:1256458-1256469) — so each is
|
||||
/// matched against an alias list, with a positional fallback in
|
||||
/// declaration order for anything unrecognised (Newtonsoft writes members
|
||||
/// in declaration order and the DTO has exactly these three).
|
||||
///
|
||||
/// The reason enum IS readable: LGEPGEHKNAG.KFCMACCLDEJ in the 2023.06
|
||||
/// dump (dump.cs:1250039-1250044) — CoC_Discriminatory=0, CoC_Sexual=1,
|
||||
/// CoC_Trolling=2, Misleading=3, Other=4, InappropriateClothing=5. That is
|
||||
/// a DIFFERENT numbering from <see cref="ReportEntity.Category"/>'s
|
||||
/// RoomieReportCategory convention (Harassment=1 … Other=5), so the
|
||||
/// ordinal is stored verbatim and the readable name is written into the
|
||||
/// message — otherwise a moderator reading the shared queue cannot tell a
|
||||
/// CoC_Sexual=1 clothing report from a Harassment=1 player report.
|
||||
///
|
||||
/// <see cref="ReportEntity"/> has no custom-avatar-item column and adding
|
||||
/// one is a migration, so the reported item's guid is carried in the
|
||||
/// stored text — the same thing the sibling screen-share report handler
|
||||
/// does with its image/instance evidence. That marker prefix is also what
|
||||
/// makes the duplicate check below work.</summary>
|
||||
[HttpPost("{id:guid}/report")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Report(Guid id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
||||
if (item is null) return NotFound();
|
||||
// The report button only renders on someone else's item, so this is an
|
||||
// impossible UI state; reject rather than pollute the queue.
|
||||
if (item.CreatorPlayerId == pid) return BadRequest(new { error = "cannot_report_own_item" });
|
||||
|
||||
var req = await ReadReportRequestAsync();
|
||||
// The dialog refuses to submit without a category ("Cannot report
|
||||
// custom clothing without a report category." —
|
||||
// Assembly-CSharp/RRUI/Data/CustomAvatarItemReportModel.txt:1941), so
|
||||
// a missing reason means a non-stock caller; bucket it as Other=4.
|
||||
var reason = req.Reason ?? 4;
|
||||
|
||||
var context = new List<string> { $"reason={ReportReasonName(reason)}({reason})" };
|
||||
// The only in-game caller passes null for the Nullable<Int32>
|
||||
// (CustomAvatarItemReportModel.txt:2008/2011 push rbx=0 for both it and
|
||||
// the trailing bool), so its meaning is unproven — keep whatever a
|
||||
// client does send rather than silently dropping evidence.
|
||||
if (req.Context is { } ctx) context.Add($"ctx={ctx}");
|
||||
|
||||
var details = (req.Details ?? string.Empty).Trim();
|
||||
var message = $"{ReportMarker(id)}{string.Join(' ', context)}] {details}".Trim();
|
||||
if (message.Length > 1000) message = message[..1000];
|
||||
|
||||
// One open report per (reporter, item): the dialog is re-openable and
|
||||
// the client retries on a failed submit, so without this a single
|
||||
// annoyed player can flood the moderation queue.
|
||||
var marker = ReportMarker(id);
|
||||
var duplicate = await db.Reports.AnyAsync(r =>
|
||||
r.ReporterPlayerId == pid && r.ResolvedAt == null && r.Message.StartsWith(marker));
|
||||
if (duplicate)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[customAvatarItems] duplicate report of {PublicId} by {Pid} ignored", id, pid);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = pid,
|
||||
TargetPlayerId = item.CreatorPlayerId,
|
||||
Category = reason,
|
||||
Message = message,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"[customAvatarItems] reported {PublicId} (creator {Creator}) by {Pid} reason={Reason}",
|
||||
id, item.CreatorPlayerId, pid, ReportReasonName(reason));
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Prefix every stored custom-item report with the item guid so
|
||||
/// the row is attributable without a dedicated column, and so the
|
||||
/// duplicate check can find prior reports with a plain LIKE.</summary>
|
||||
private static string ReportMarker(Guid id) => $"[customAvatarItem {id:D} ";
|
||||
|
||||
/// <summary>LGEPGEHKNAG.KFCMACCLDEJ, verbatim from the 2023.06 dump
|
||||
/// (dump.cs:1250039-1250044). Index IS the wire ordinal.</summary>
|
||||
private static readonly string[] ReportReasonNames =
|
||||
{
|
||||
"CoC_Discriminatory", "CoC_Sexual", "CoC_Trolling",
|
||||
"Misleading", "Other", "InappropriateClothing",
|
||||
};
|
||||
|
||||
private static string ReportReasonName(int reason) =>
|
||||
reason >= 0 && reason < ReportReasonNames.Length ? ReportReasonNames[reason] : "Unknown";
|
||||
|
||||
/// <summary>Read the report body. The stock client sends raw JSON, but
|
||||
/// form encodings are accepted too so an admin/tooling caller can post the
|
||||
/// same three fields flat.</summary>
|
||||
private async Task<ReportRequest> ReadReportRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
string? Field(params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
if (form[key].FirstOrDefault() is { } v) return v;
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReportRequest
|
||||
{
|
||||
Reason = ParseReportReason(Field("reason", "Reason", "reportReason", "ReportReason",
|
||||
"category", "Category", "reportCategory", "ReportCategory", "type", "Type")),
|
||||
Details = Field("details", "Details", "description", "Description",
|
||||
"message", "Message", "comment", "Comment"),
|
||||
Context = int.TryParse(
|
||||
Field("contextId", "ContextId", "instanceId", "InstanceId",
|
||||
"roomInstanceId", "RoomInstanceId", "roomId", "RoomId",
|
||||
"accountId", "AccountId", "playerId", "PlayerId"),
|
||||
out var ctx)
|
||||
? ctx
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
return ParseReportJson(await reader.ReadToEndAsync());
|
||||
}
|
||||
|
||||
/// <summary>Lenient JSON → <see cref="ReportRequest"/>. Alias-matched
|
||||
/// first; anything left over is assigned positionally in JEHCPEEKMML's
|
||||
/// declaration order (reason, detail, nullable int) so an unguessed key
|
||||
/// spelling still lands in the right slot.</summary>
|
||||
private static ReportRequest ParseReportJson(string json)
|
||||
{
|
||||
var req = new ReportRequest();
|
||||
if (string.IsNullOrWhiteSpace(json)) return req;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return req;
|
||||
|
||||
var unmatched = new List<JsonElement>();
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "reason" or "reportreason" or "category" or "reportcategory" or "type" or "reporttype":
|
||||
req.Reason ??= ParseReportReason(prop.Value);
|
||||
break;
|
||||
case "details" or "detail" or "description" or "reportdescription"
|
||||
or "message" or "comment" or "text":
|
||||
req.Details ??= AsString(prop.Value);
|
||||
break;
|
||||
case "contextid" or "instanceid" or "roominstanceid" or "roomid"
|
||||
or "accountid" or "playerid" or "reportedplayerid":
|
||||
req.Context ??= AsInt(prop.Value);
|
||||
break;
|
||||
default:
|
||||
unmatched.Add(prop.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var value in unmatched)
|
||||
{
|
||||
if (req.Reason is null && ParseReportReason(value) is { } reason)
|
||||
{
|
||||
req.Reason = reason;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (req.Details is null && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
req.Details = value.GetString();
|
||||
continue;
|
||||
}
|
||||
|
||||
req.Context ??= AsInt(value);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
/// <summary>Accept the reason as an ordinal or as one of the enum's own
|
||||
/// names — Newtonsoft writes an int by default, but a StringEnumConverter
|
||||
/// on the DTO (an attribute the dump doesn't render) would write the name.</summary>
|
||||
private static int? ParseReportReason(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null,
|
||||
JsonValueKind.String => ParseReportReason(value.GetString()),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static int? ParseReportReason(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
if (int.TryParse(value, out var ordinal)) return ordinal;
|
||||
var index = Array.FindIndex(
|
||||
ReportReasonNames,
|
||||
n => string.Equals(n, value, StringComparison.OrdinalIgnoreCase));
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
|
||||
private async Task EnsureOwnershipAsync(long playerId, long itemId)
|
||||
{
|
||||
if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
|
||||
o.PlayerId == playerId && o.CustomAvatarItemId == itemId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.CustomAvatarItemOwnership.Add(new CustomAvatarItemOwnershipEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
CustomAvatarItemId = itemId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Collect the bulk request's guid list. The 2023 client's key is
|
||||
/// <c>"customAvatarItemIds"</c> (MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:242);
|
||||
/// older callers use ids/itemIds. Which transport the request builder picks
|
||||
/// for a POST collection param isn't decidable from ISIL, so all three are
|
||||
/// read: raw JSON body, form fields and query pairs. The body is read
|
||||
/// manually — a [FromBody] parameter would 415 a form-encoded POST before
|
||||
/// the handler ran.</summary>
|
||||
private async Task<List<Guid>> ReadIdsAsync()
|
||||
{
|
||||
var result = new List<Guid>();
|
||||
void AddCsv(string? value)
|
||||
{
|
||||
foreach (var part in (value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (Guid.TryParse(part, out var id)) result.Add(id);
|
||||
}
|
||||
|
||||
if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
// Either a bare guid array or an object carrying one under any
|
||||
// of the accepted key spellings.
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
AddJsonGuids(root, result);
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (!IdListKeys.Contains(prop.Name)) continue;
|
||||
if (prop.Value.ValueKind == JsonValueKind.Array) AddJsonGuids(prop.Value, result);
|
||||
else if (prop.Value.ValueKind == JsonValueKind.String) AddCsv(prop.Value.GetString());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pair in Request.Query)
|
||||
foreach (var value in pair.Value)
|
||||
AddCsv(value);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var field in form)
|
||||
{
|
||||
if (!IdListKeys.Contains(field.Key)) continue;
|
||||
foreach (var value in field.Value) AddCsv(value);
|
||||
}
|
||||
}
|
||||
|
||||
return result.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private static void AddJsonGuids(JsonElement array, List<Guid> into)
|
||||
{
|
||||
foreach (var el in array.EnumerateArray())
|
||||
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var id))
|
||||
into.Add(id);
|
||||
}
|
||||
|
||||
/// <summary>Accepted spellings of the bulk guid-list key. Case-insensitive
|
||||
/// set so "customAvatarItemIds" / "CustomAvatarItemIds" both land.</summary>
|
||||
private static readonly HashSet<string> IdListKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"customAvatarItemIds", "ids", "itemIds",
|
||||
};
|
||||
|
||||
/// <summary>Read the fields of a create / update / design-save request.
|
||||
/// Three encodings have to work: the designer's multipart bodies wrap
|
||||
/// everything in a single <c>"metadata"</c> form field holding JSON
|
||||
/// (MPBLNLMCEDL.txt:1362 and MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:210),
|
||||
/// PUT {id} sends a raw JSON body, and older/admin callers post flat form
|
||||
/// fields. The JSON key names are not in the dump — they live in attribute
|
||||
/// metadata Cpp2IL doesn't render — so each field is matched against an
|
||||
/// alias list rather than one guessed spelling. Only the field ORDER is
|
||||
/// proven (from the arg-store sequence); the names are inferred.</summary>
|
||||
private async Task<DesignRequest> ReadDesignRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var metadata = form["metadata"].FirstOrDefault() ?? form["Metadata"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(metadata))
|
||||
return ParseDesignJson(metadata);
|
||||
|
||||
string? Field(params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
if (form[key].FirstOrDefault() is { } v) return v;
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DesignRequest
|
||||
{
|
||||
Id = Guid.TryParse(Field("id", "Id"), out var id) ? id : null,
|
||||
Name = Field("name", "Name"),
|
||||
Description = Field("description", "Description"),
|
||||
Price = int.TryParse(Field("price", "Price"), out var price) ? price : null,
|
||||
ItemType = int.TryParse(Field("itemType", "ItemType", "permission", "Permission"), out var itemType) ? itemType : null,
|
||||
BaseAvatarItemId = int.TryParse(Field("baseAvatarItemId", "BaseAvatarItemId"), out var baseId) ? baseId : null,
|
||||
Color = Field("color", "Color"),
|
||||
ImageName = Field("imageName", "ImageName"),
|
||||
AssetName = Field("assetName", "AssetName"),
|
||||
IsPublic = bool.TryParse(Field("isPublic", "IsPublic"), out var pub) ? pub : null,
|
||||
};
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
return ParseDesignJson(await reader.ReadToEndAsync());
|
||||
}
|
||||
|
||||
/// <summary>Lenient JSON → <see cref="DesignRequest"/>. Members that are
|
||||
/// absent stay null so callers can tell "not sent" from "sent empty" —
|
||||
/// PUT {id} relies on that to leave untouched fields alone.</summary>
|
||||
private static DesignRequest ParseDesignJson(string json)
|
||||
{
|
||||
var req = new DesignRequest();
|
||||
if (string.IsNullOrWhiteSpace(json)) return req;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return req;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
var value = prop.Value;
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "id" or "customavataritemid":
|
||||
if (value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var id)) req.Id = id;
|
||||
break;
|
||||
case "name" or "itemname" or "title":
|
||||
req.Name = AsString(value);
|
||||
break;
|
||||
case "description" or "desc":
|
||||
req.Description = AsString(value);
|
||||
break;
|
||||
case "price":
|
||||
req.Price = AsInt(value);
|
||||
break;
|
||||
// The enum at DCKDCBDHDKI+0x38 / NOAAFNFJPFB+0x3C is the
|
||||
// item's permission (the 06.21 dump names its config row
|
||||
// CustomAvatarItemPermissionFriendlyNameMapping); this
|
||||
// schema calls that column ItemType, hence both aliases.
|
||||
case "itemtype" or "type" or "permission" or "avataritemtype":
|
||||
req.ItemType = AsInt(value);
|
||||
break;
|
||||
case "baseavataritemid" or "baseitemid" or "avataritemid":
|
||||
req.BaseAvatarItemId = AsInt(value);
|
||||
break;
|
||||
case "color" or "colorhex" or "tint":
|
||||
req.Color = AsString(value);
|
||||
break;
|
||||
case "imagename" or "thumbnail" or "thumbnailimage":
|
||||
req.ImageName = AsString(value);
|
||||
break;
|
||||
case "assetname" or "design" or "designname":
|
||||
req.AssetName = AsString(value);
|
||||
break;
|
||||
case "ispublic" or "public":
|
||||
req.IsPublic = value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String => bool.TryParse(value.GetString(), out var b) ? b : null,
|
||||
_ => null,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
private static string? AsString(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Number => value.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static int? AsInt(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null,
|
||||
JsonValueKind.String => int.TryParse(value.GetString(), out var s) ? s : null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Pick out the create request's two file parts. Named lookup
|
||||
/// first ("thumbnailImage" / "design", both sent with filename
|
||||
/// "file.bin" — MPBLNLMCEDL.txt:1372-1390), then positional order as a
|
||||
/// fallback.</summary>
|
||||
private (IFormFile? Thumbnail, IFormFile? Asset) ReadItemFileParts()
|
||||
{
|
||||
if (!Request.HasFormContentType) return (null, null);
|
||||
var files = Request.Form.Files;
|
||||
if (files.Count == 0) return (null, null);
|
||||
var thumbnail = files.GetFile("thumbnailImage") ?? files.GetFile("thumbnail");
|
||||
var asset = files.GetFile("design") ?? files.GetFile("asset");
|
||||
if (thumbnail is null && asset is null)
|
||||
{
|
||||
thumbnail = files[0];
|
||||
asset = files.Count > 1 ? files[1] : null;
|
||||
}
|
||||
|
||||
return (thumbnail?.Length > 0 ? thumbnail : null, asset?.Length > 0 ? asset : null);
|
||||
}
|
||||
|
||||
/// <summary>Persist one uploaded image part and return its BlobName. Same
|
||||
/// shape as the images controller: S3 (or the disk fallback) holds the
|
||||
/// bytes, a RoomDataBlobs row keeps the uploader audit trail, and the
|
||||
/// cdn serves it straight off the BlobName.</summary>
|
||||
private async Task<string> StoreImageBlobAsync(long playerId, IFormFile file, string what)
|
||||
{
|
||||
var bytes = await ReadFileBytesAsync(file);
|
||||
var contentType = SniffImageContentType(bytes);
|
||||
var blobName = $"img_p{playerId}_{Guid.NewGuid():N}.{(contentType == "image/jpeg" ? "jpg" : "png")}";
|
||||
await PutBlobAsync(blobName, bytes, contentType, what);
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = 0,
|
||||
BlobName = blobName,
|
||||
UploadedByPlayerId = playerId,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
ReferencedFilenamesCsv = string.Empty,
|
||||
});
|
||||
return blobName;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadFileBytesAsync(IFormFile file)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>The parts are sent with filename "file.bin" and no usable
|
||||
/// content type, so sniff the magic bytes instead of trusting either.</summary>
|
||||
private static string SniffImageContentType(byte[] bytes) =>
|
||||
bytes.Length >= 4 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 ? "image/png"
|
||||
: bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF ? "image/jpeg"
|
||||
: "image/png";
|
||||
|
||||
private async Task PutBlobAsync(string blobName, byte[] bytes, string contentType, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
await storage.PutAsync(bucket, key, bytes, contentType, timeout.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[customAvatarItems] storing {What} blob {Blob} failed", what, blobName);
|
||||
}
|
||||
}
|
||||
|
||||
// ── In-progress shirt design ────────────────────────────────────────────
|
||||
// The designer's draft (base item + colour + texture) has no table in this
|
||||
// schema and adding one is a migration, so it lives in object storage
|
||||
// under two deterministic per-player blob names: overwrite-in-place on
|
||||
// save, tombstoned on delete.
|
||||
private static string DesignMetaBlobName(long playerId) => $"caidesign_p{playerId}.json";
|
||||
|
||||
private static string DesignImageBlobName(long playerId) => $"caidesign_p{playerId}.png";
|
||||
|
||||
private async Task<StoredDesign?> LoadDesignAsync(long playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
var (bucket, key) = BlobRouter.Route(DesignMetaBlobName(playerId));
|
||||
var bytes = await storage.GetAsync(bucket, key, timeout.Token);
|
||||
if (bytes is null || bytes.Length == 0) return null;
|
||||
var design = JsonSerializer.Deserialize<StoredDesign>(
|
||||
bytes,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return design is null || design.Deleted ? null : design;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
logger.LogWarning(ex, "[customAvatarItems] unreadable saved design for player {Pid}", playerId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Task SaveDesignAsync(long playerId, StoredDesign design) =>
|
||||
PutBlobAsync(
|
||||
DesignMetaBlobName(playerId),
|
||||
JsonSerializer.SerializeToUtf8Bytes(design),
|
||||
"application/json",
|
||||
"custom avatar design record");
|
||||
|
||||
private object ToWire(CustomAvatarItemEntity item) => new
|
||||
{
|
||||
Id = item.PublicId,
|
||||
CustomAvatarItemId = item.PublicId,
|
||||
CreatorId = (int)item.CreatorPlayerId,
|
||||
CreatorPlayerId = (int)item.CreatorPlayerId,
|
||||
item.Name,
|
||||
item.Description,
|
||||
item.Price,
|
||||
item.ItemType,
|
||||
item.BaseAvatarItemId,
|
||||
item.Color,
|
||||
item.ImageName,
|
||||
ImageUrl = string.IsNullOrWhiteSpace(item.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{item.ImageName}",
|
||||
item.AssetName,
|
||||
item.IsPublic,
|
||||
item.IsFeatured,
|
||||
item.CheerCount,
|
||||
item.PurchaseCount,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
Status = item.IsPublic ? 1 : 0,
|
||||
};
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
public sealed class BulkRequest
|
||||
{
|
||||
public List<Guid>? Ids { get; set; }
|
||||
public List<Guid>? ItemIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The designer's in-progress draft, as persisted to the
|
||||
/// per-player <c>caidesign_p<id>.json</c> object (see
|
||||
/// <see cref="LoadDesignAsync"/>). Mirrors the three payload properties of
|
||||
/// the client's design DTOs — the metadata object GIOBJIGEMNG carries the
|
||||
/// nullable base-item id and the "#RRGGBB" colour, and the texture arrives
|
||||
/// as the separate "design" file part whose stored blob name lands in
|
||||
/// <see cref="ImageName"/>.
|
||||
///
|
||||
/// <see cref="Deleted"/> is the tombstone: <see cref="IObjectStorage"/>
|
||||
/// exposes no delete operation, so DELETE design overwrites the record with
|
||||
/// a deleted marker and LoadDesignAsync reports it as "no saved design".
|
||||
/// Public properties with setters because the round-trip goes through
|
||||
/// System.Text.Json.</summary>
|
||||
public sealed class StoredDesign
|
||||
{
|
||||
public int? BaseAvatarItemId { get; set; }
|
||||
|
||||
public string Color { get; set; } = string.Empty;
|
||||
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
public bool Deleted { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The three members of the client's report DTO JEHCPEEKMML,
|
||||
/// in its declaration order: the reason enum (+0x10), the free-text
|
||||
/// detail (+0x18) and an unidentified Nullable<Int32> (+0x20) that
|
||||
/// the stock report dialog always leaves null. All nullable so
|
||||
/// "not sent" is distinguishable from "sent empty".</summary>
|
||||
public sealed class ReportRequest
|
||||
{
|
||||
public int? Reason { get; set; }
|
||||
public string? Details { get; set; }
|
||||
public int? Context { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DesignRequest
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? Price { get; set; }
|
||||
public int? ItemType { get; set; }
|
||||
public int? BaseAvatarItemId { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public string? AssetName { get; set; }
|
||||
public bool? IsPublic { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Events.V3;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v3")]
|
||||
public class EventsController(DorkNetDbContext db) : ControllerBase
|
||||
public class EventsController : ControllerBase
|
||||
{
|
||||
[HttpGet("list")]
|
||||
public async Task<ActionResult<List<object>>> GetActiveEvents([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(take)
|
||||
.Select(e => new
|
||||
{
|
||||
EventId = e.Id,
|
||||
PlayerEventId = e.Id,
|
||||
Name = e.Title,
|
||||
e.Description,
|
||||
StartTime = e.StartsAt,
|
||||
EndTime = e.EndsAt,
|
||||
CreatorPlayerId = (int)e.CreatorPlayerId,
|
||||
e.RoomId,
|
||||
e.Capacity,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows.Cast<object>().ToList());
|
||||
}
|
||||
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user