mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 212d4327da | |||
| b5dec4ba1b | |||
| 0cc78fbf9d | |||
| d9494373a9 | |||
| 2d6a6ec94c | |||
| 8c4f33ca80 | |||
| c50b305786 | |||
| 71df7a3ef1 | |||
| da596a2692 | |||
| 2eba82b342 | |||
| 579071858a | |||
| 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
|
||||
|
||||
+3
-24
@@ -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
|
||||
@@ -21,7 +22,7 @@ tmp/
|
||||
## OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.backups
|
||||
|
||||
## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit)
|
||||
dump.cs
|
||||
il2cpp.h
|
||||
@@ -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,30 +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/**
|
||||
local-releases.zip
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
# DorkNet.Server container image — Coolify deploy target.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Multi-stage: SDK image builds + publishes, runtime image is the
|
||||
# slimmer aspnet:9.0 with only the published output.
|
||||
# slimmer aspnet:10.0 with only the published output.
|
||||
#
|
||||
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
||||
# to ProjectReference + dotnet restore). The .dockerignore at the
|
||||
# repo root keeps the bin/, obj/, and other dev junk out of the
|
||||
# context.
|
||||
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG DOTNET_VERSION=10.0
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
# ── admin SPA build stage ──────────────────────────────────────────
|
||||
@@ -76,13 +76,13 @@ WORKDIR /app
|
||||
|
||||
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
||||
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
||||
# both have to be present. The aspnet:9.0 base image strips both by
|
||||
# both have to be present. The aspnet:10.0 base image strips both by
|
||||
# default to keep the surface area small.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
|
||||
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
|
||||
# Coolify mounts persistent volumes with write permission for this UID.
|
||||
USER app
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+160
-1430
@@ -43,11 +43,10 @@ public class Mod : MelonMod
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _networkPatchesRegistered;
|
||||
private bool _joinTracePatchesRegistered;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private int _diagnosticRetryFrame;
|
||||
private bool _firstUpdateLogged;
|
||||
private int _photonOverridePollFrame;
|
||||
|
||||
// Config backed by a plain JSON file under MelonLoader/UserData.
|
||||
// The defaults below match the recommended install, so dropping this
|
||||
@@ -55,6 +54,7 @@ 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
|
||||
@@ -67,19 +67,9 @@ public class Mod : MelonMod
|
||||
// any /goto has returned. Default matches the server's
|
||||
// appsettings.json Photon:CloudRegion ("eu").
|
||||
public static string PhotonCloudRegion = "eu";
|
||||
// Photon Custom Auth injector parked 2026-05-28; see
|
||||
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
|
||||
public static bool EnableTlsTrustBypass = true;
|
||||
// Log-only trace for the stuck first-dorm "Change Username?"
|
||||
// dialog. When true, RegistrationPatches hooks the dorm
|
||||
// registration-prompt flow + the confirm-dialog buttons and writes
|
||||
// what fires to MelonLoader/UserData/dorknet-diagnostics.log, so a
|
||||
// brand-new account that reproduces the freeze tells us exactly
|
||||
// where it breaks (does the click reach Button_Affirmative? does
|
||||
// the prompt even run?). No behaviour change — purely diagnostic.
|
||||
// See RegistrationPatches and the registration site in
|
||||
// OnLateInitializeMelon.
|
||||
public static bool TraceRegistrationDialog = true;
|
||||
// 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
|
||||
@@ -87,68 +77,6 @@ public class Mod : MelonMod
|
||||
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
|
||||
// ── Desktop Screen Sharing gadget FPS ──
|
||||
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
|
||||
// the shared desktop image. Its cadence is a baked [SerializeField]
|
||||
// float screenShareImageRefreshFrequency — the broadcast tick gates
|
||||
// on (Time.time - lastSend >= 1f / frequency), verified at
|
||||
// DesktopScreenSharingDisplay.txt:697-703, so the field IS the FPS
|
||||
// and its editor-only [Range] is NOT enforced at runtime. Set a
|
||||
// target here to override it (default prefab value is low, ~5).
|
||||
// 0 = leave the prefab value alone (patch not even registered).
|
||||
public static float DesktopScreenShareFps = 0f;
|
||||
// The captured frame ships via the gadget's IPunObservable
|
||||
// OnPhotonSerializeView, which only fires at
|
||||
// PhotonNetwork.SerializationRate (default 10 Hz) — so 30 fps capture
|
||||
// still delivers ~10 unless we raise it. This lifts SendRate +
|
||||
// SerializationRate to >= the FPS target. GLOBAL: affects every
|
||||
// PhotonView's sync traffic, so turn off if it lags a busy room.
|
||||
public static bool DesktopScreenShareRaisePhotonRate = true;
|
||||
// Optional per-frame size knobs (0 = leave the prefab value). Higher
|
||||
// FPS needs smaller frames to fit the Photon serialization budget;
|
||||
// lower these if 30 fps saturates bandwidth (chunked frames stall).
|
||||
public static int DesktopScreenShareResolution = 0; // horizontal px
|
||||
public static int DesktopScreenShareQuality = 0; // JPEG quality
|
||||
|
||||
// One-shot dev-UI diagnostic (no behaviour change). When true,
|
||||
// ~5s after load the mod logs whether the watch's native developer
|
||||
// menu content actually exists in this build (HomeScreenFlow.
|
||||
// devMenuPanel / devButtonPrefab present vs NULL), the dev-flow list
|
||||
// counts, and whether RecRoom.Debugging.DebugConsole is in the scene
|
||||
// + whether its static Execute() command path works. This tells us
|
||||
// stripped-vs-gated so we know whether to un-gate the game's own dev
|
||||
// menu or build a custom watch entry. Output goes to the MelonLoader
|
||||
// console + dorknet-diagnostics.log.
|
||||
public static bool DiagnoseDevMenu = false;
|
||||
|
||||
// Inject a "Dev Console" button into the watch's native dev-menu
|
||||
// panel (HomeScreenFlow.devMenuPanel) by cloning the game's own
|
||||
// devButtonPrefab. The panel/prefab exist in this build (just gated
|
||||
// off), so we instantiate + activate + label, then wire its click to
|
||||
// run console commands. Native Button3D → works in VR and Screen Mode.
|
||||
public static bool EnableDevWatchButton = false;
|
||||
// Preset dev commands shown as watch buttons (when EnableDevWatchButton
|
||||
// is on). Each entry is "Label=command"; clicking runs
|
||||
// DebugConsole.Execute(command). Edit to match this build's actual
|
||||
// DebugConsoleCommandConfig command names/args.
|
||||
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
|
||||
|
||||
// Raise RRO quest party size past the baked 4. The cap lives in each
|
||||
// quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize
|
||||
// ScriptableObject (baked in the client bundle; no server lever). The
|
||||
// quest scoreboard + party HUD build rows dynamically, so they
|
||||
// auto-scale once the team cap is raised. 0 = off (leave at 4).
|
||||
// Only single-team, quest-named configs are touched, so PvP team
|
||||
// sizes (paintball/laser tag) are never changed. See QuestTeamSize.
|
||||
public static int QuestMaxTeamSize = 0;
|
||||
// Only these RRO quest GameConfigurationAssets get their team size
|
||||
// bumped (matched loosely: case/space-insensitive, filler words like
|
||||
// "the/of/for/quest" ignored, so "IsleOfLostSkulls" matches the
|
||||
// in-game "Isle of the Lost Skulls"). Empty = bump nothing. The
|
||||
// generic "Quest" config is intentionally NOT in the default list.
|
||||
public static string[] QuestMaxTeamSizeRooms =
|
||||
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
|
||||
}
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
@@ -167,53 +95,14 @@ public class Mod : MelonMod
|
||||
RegisterNetworkPatches();
|
||||
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
|
||||
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
|
||||
// Hook the watch's own AppSettings + AuthValues builders directly.
|
||||
// Both are private PUNNetworkManager methods with obfuscated 2020.12
|
||||
// names — we know they always run before any Photon connect because
|
||||
// ConnectUsingSettings reads their return values. Postfix logs them
|
||||
// after construction.
|
||||
//
|
||||
// FJOLIPKKIBE returns Photon.Realtime.AppSettings — verified at
|
||||
// PUNNetworkManager.txt:7046. One bool arg (DOJLKMCEFFB — likely
|
||||
// "for voice"). Two call paths in the binary.
|
||||
// EMGGKBALALH returns Photon.Realtime.AuthenticationValues —
|
||||
// verified at PUNNetworkManager.txt:7560. Builds AuthValues with
|
||||
// environment + accessToken AuthGetParameters.
|
||||
//
|
||||
// Quest-Dec uses the same obfuscation seed as PC-Dec, but discover by
|
||||
// return type first so a future platform/reseed does not make these
|
||||
// two critical hooks depend on the private method names.
|
||||
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AppSettings",
|
||||
fallbackMethodName: "FJOLIPKKIBE",
|
||||
postfix: nameof(PhotonPatches.LogAppSettings_Postfix));
|
||||
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AuthenticationValues",
|
||||
fallbackMethodName: "EMGGKBALALH",
|
||||
postfix: nameof(PhotonPatches.LogAuthValues_Postfix));
|
||||
// PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever
|
||||
// string-like arg the IL2CPP method takes (declared `string` in
|
||||
// dump.cs but Il2CppInterop wraps it in a way that breaks
|
||||
// Harmony's IL emit when we declare `string` directly). Using
|
||||
// `object` lets Harmony pass through whatever wrapper the IL2CPP
|
||||
// proxy uses without compile-error.
|
||||
TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed",
|
||||
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix));
|
||||
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
|
||||
// Even when the prefix's return value is "let original run", the
|
||||
// detour itself still has to be JIT-installed when BouncyCastle
|
||||
// does its first handshake, and MonoMod's CompileMethodHook
|
||||
// fatal-CLRs (0x80131506) installing trampolines into these
|
||||
// third-party interface implementations. Only register the
|
||||
// detours if the bypass is actually wanted; on a setup with
|
||||
// valid certs (the default) skip the patch entirely.
|
||||
if (Cfg.EnableTlsTrustBypass)
|
||||
{
|
||||
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
}
|
||||
// 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
|
||||
@@ -301,236 +190,21 @@ public class Mod : MelonMod
|
||||
"RpcChatEmote",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
|
||||
|
||||
// Diagnostic trace for the stuck first-dorm "Change Username?"
|
||||
// dialog (new accounts can't get past it; "Okay" highlights but
|
||||
// never dismisses the modal, on every device). This is LOG-ONLY —
|
||||
// no behaviour change. It bisects the failure for a brand-new
|
||||
// account that reproduces the freeze, writing to
|
||||
// MelonLoader/UserData/dorknet-diagnostics.log:
|
||||
//
|
||||
// 1. RegistrationModel.IsFullyRegistered() return value — if
|
||||
// false, the dorm prompt path runs; if true, no prompt (so a
|
||||
// freeze here would be something else entirely).
|
||||
// 2. DormroomSceneManager.<PromptForRegistration>b__6_* lambdas —
|
||||
// which prompt branch executed (b__6_1 = a dialog was raised).
|
||||
// 3. ConfirmUIDialog.Button_Affirmative/Negative/NotNow/Cancel —
|
||||
// whether the click actually reaches the dialog's button
|
||||
// handler when the user taps Okay. If these FIRE but the modal
|
||||
// stays, the break is in the resolve/hide completer
|
||||
// (UIDialog`1.KHKDGPHPIND's guard); if they DON'T fire, the
|
||||
// click never routes to the button (input/raycast) and the fix
|
||||
// is elsewhere. Either way the next step is unambiguous.
|
||||
if (Cfg.TraceRegistrationDialog)
|
||||
// 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("RRUI.Data.NUX.RegistrationModel",
|
||||
"IsFullyRegistered",
|
||||
args: Type.EmptyTypes,
|
||||
postfix: nameof(RegistrationPatches.IsFullyRegistered_Postfix));
|
||||
|
||||
// The two registration dialogs are raised from
|
||||
// DormroomSceneManager's PromptForRegistration via its
|
||||
// compiler-generated <>c lambdas (b__6_0..b__6_3). Reuse the
|
||||
// same nested-lambda patcher the join trace uses.
|
||||
PatchNestedLambdas("DormroomSceneManager", "PromptForRegistration",
|
||||
nameof(RegistrationPatches.PromptLambda_Prefix));
|
||||
|
||||
// The confirm-dialog buttons. Clean, non-generic, no-arg names
|
||||
// on AGUI.StackedUI.Dialog.ConfirmUIDialog — Okay maps to
|
||||
// Button_Affirmative, the screenshot's only button.
|
||||
foreach (var (method, label) in new[]
|
||||
{
|
||||
("Button_Affirmative", nameof(RegistrationPatches.ButtonAffirmative_Prefix)),
|
||||
("Button_Negative", nameof(RegistrationPatches.ButtonNegative_Prefix)),
|
||||
("Button_NotNow", nameof(RegistrationPatches.ButtonNotNow_Prefix)),
|
||||
("Button_Cancel", nameof(RegistrationPatches.ButtonCancel_Prefix)),
|
||||
})
|
||||
{
|
||||
TryPatchByName("AGUI.StackedUI.Dialog.ConfirmUIDialog",
|
||||
method, args: Type.EmptyTypes, prefix: label);
|
||||
}
|
||||
}
|
||||
|
||||
RegisterJoinTracePatches();
|
||||
|
||||
// Desktop Screen Sharing FPS override. Only register when a target
|
||||
// is set; the postfix on the gadget's Awake rewrites the baked
|
||||
// refresh-frequency field (and, if enabled, raises the Photon
|
||||
// serialization rate so the higher capture rate actually transmits).
|
||||
if (Cfg.DesktopScreenShareFps > 0f)
|
||||
{
|
||||
TryPatchByName("RecRoom.Tools.Productivity.DesktopScreenSharingDisplay",
|
||||
"Awake", args: Type.EmptyTypes,
|
||||
postfix: nameof(ScreenSharePatches.DisplayAwake_Postfix));
|
||||
Log.Msg($"[screenshare] override armed: target {Cfg.DesktopScreenShareFps} fps " +
|
||||
$"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})");
|
||||
}
|
||||
|
||||
TryPatchByName("RecRoom.Tools.MakerPenVisuals",
|
||||
"set_LaserPointerEnabled",
|
||||
args: new[] { typeof(bool) },
|
||||
prefix: nameof(MakerPenGiftPreviewPatches.LaserPointerEnabled_Prefix));
|
||||
|
||||
// RRO quest party-size bump. Prefix the runtime config-apply path so
|
||||
// we can read the config's Name (scope to the allowlisted quests) and
|
||||
// bump GameConfigurationData.TeamConfigurations[].TeamSize before the
|
||||
// team manager / pre-game roster reads it. Only when a target is set.
|
||||
if (Cfg.QuestMaxTeamSize > 0)
|
||||
{
|
||||
// The runtime team cap is built from the baked GameConfigurationAsset
|
||||
// (a readable ScriptableObject) by GameConfigurationAsset.GDECPANDBHJ()
|
||||
// — it reads asset.TeamConfigurations[i].MaxTeamSize (a typed int
|
||||
// struct-array) into the protobuf TeamSize. Prefix that builder and
|
||||
// bump the source array IN PLACE before it converts, scoped by
|
||||
// asset.Name. This avoids the obfuscated protobuf entirely (its
|
||||
// generic RepeatedField getter isn't emitted by Il2CppInterop).
|
||||
// Host-authoritative: the quest host must run the mod. See QuestTeamSize.
|
||||
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "GDECPANDBHJ",
|
||||
prefix: nameof(QuestTeamSize.Build_Prefix));
|
||||
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
|
||||
TryPatchByName("GameSpawnManager", "FKEICBPCMPJ",
|
||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
|
||||
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 ===");
|
||||
}
|
||||
|
||||
// ── Room-join + watch-button trace ────────────────────────────────
|
||||
// Traces the full "press the Dorm Room button → join the room" chain
|
||||
// so a stuck join (e.g. the watch's Dorm Room button doing nothing)
|
||||
// is debuggable from the log alone. The chain, verified against the
|
||||
// 2020.12.18 IL2CPP dump:
|
||||
//
|
||||
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() (button)
|
||||
// → SessionManager.RunJoinRoom("DormRoom", …) (join entry)
|
||||
// → OJMCBOKJFOF.NHBPIIGDAJP(name) → GET /goto/room/{name}
|
||||
// rejects with "No such room" (OJMCBOKJFOF.txt:958-1019)
|
||||
// → .Then(SessionManager.RunJoinRoom(RoomInstance, …))
|
||||
// → on reject: SessionManager+<>c.<RunJoinRoom>b__165_1(error)
|
||||
// → NotificationManager.Play("An error occured", "…happyfox…")
|
||||
// (the "contact recroom.happyfox.com" toast)
|
||||
//
|
||||
// We can't express the obfuscated RunJoinRoom param types as a Type[]
|
||||
// for AccessTools, so every RunJoinRoom overload + every /goto overload
|
||||
// is patched by iterating declared methods by name, and the prefixes
|
||||
// read arguments generically via Harmony's `object[] __args`.
|
||||
private void RegisterJoinTracePatches()
|
||||
{
|
||||
if (_joinTracePatchesRegistered) return;
|
||||
Log.Msg("=== Registering room-join trace ===");
|
||||
|
||||
// Watch "Dorm Room" button. Void, no params, unique name —
|
||||
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
|
||||
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
|
||||
|
||||
// Every SessionManager.RunJoinRoom overload (string-name entry +
|
||||
// the RoomInstance inner legs). Logs the method + its arguments.
|
||||
PatchAllOverloads("SessionManager", "RunJoinRoom",
|
||||
nameof(JoinPatches.RunJoinRoom_Prefix));
|
||||
|
||||
// The /goto/room/{name|id} promise builders — both the string
|
||||
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
|
||||
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
|
||||
nameof(JoinPatches.Goto_Prefix));
|
||||
|
||||
// The RunJoinRoom promise-chain callbacks compiled onto the
|
||||
// SessionManager <>c display class — most importantly the reject
|
||||
// handler b__165_1(string error) that raises the happyfox toast.
|
||||
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
|
||||
// marker "b__" so we hit the callbacks but not the RunJoinRoom
|
||||
// state-machine MoveNext.
|
||||
PatchNestedLambdas("SessionManager", "RunJoinRoom",
|
||||
nameof(JoinPatches.JoinCallback_Prefix));
|
||||
|
||||
// Additionally capture the REJECT REASON string on b__165_1 (the
|
||||
// happyfox-toast handler). Matches only that one lambda; it ends
|
||||
// up with two prefixes (flow + error string), which is harmless.
|
||||
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
|
||||
nameof(JoinPatches.JoinError_Prefix));
|
||||
|
||||
_joinTracePatchesRegistered = true;
|
||||
Log.Msg("=== Room-join trace registered ===");
|
||||
}
|
||||
|
||||
// Patch every declared method on `typeName` named `methodName`,
|
||||
// regardless of signature, with a single prefix. Used for obfuscated
|
||||
// overload sets whose param types we can't name in a Type[].
|
||||
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): type not found");
|
||||
return false;
|
||||
}
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != methodName) continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
|
||||
}
|
||||
}
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
|
||||
return false;
|
||||
}
|
||||
Log.Msg($"[patch-ok] {typeName}.{methodName}(*) x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Patch compiler-generated lambda methods (names containing "b__")
|
||||
// on the nested display classes of `typeName` whose (de-mangled) name
|
||||
// also contains `nameContains`. Il2CppInterop mangles the angle
|
||||
// brackets of `<RunJoinRoom>b__165_1` to `_003C…_003E` but leaves the
|
||||
// method name itself intact, so a plain substring match still works.
|
||||
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
|
||||
return false;
|
||||
}
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
var patched = 0;
|
||||
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
|
||||
{
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(nested))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (method.Name.IndexOf("b__", StringComparison.Ordinal) < 0) continue;
|
||||
if (method.Name.IndexOf(nameContains, StringComparison.Ordinal) < 0) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] {typeName}+{nested.Name}.{method.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}+{nested.Name}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patched == 0 && logMiss)
|
||||
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
|
||||
return patched > 0;
|
||||
}
|
||||
|
||||
private void RegisterNetworkPatches()
|
||||
{
|
||||
if (_networkPatchesRegistered) return;
|
||||
@@ -584,11 +258,19 @@ public class Mod : MelonMod
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (!_firstUpdateLogged)
|
||||
{
|
||||
_firstUpdateLogged = true;
|
||||
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
|
||||
}
|
||||
// Photon AppId override poller. The harmony hook on
|
||||
// PhotonNetwork.ConnectUsingSettings registers in IL2CPP but
|
||||
// doesn't actually fire in this build (verified in the
|
||||
// MelonLoader log — [patch-ok] appears but [photon-appid]
|
||||
// never does), so we poll until PhotonServerSettings is loaded
|
||||
// and write the AppId then. Stops calling once the override
|
||||
// succeeds. Throttled to every 6th frame (~10 Hz at 60 fps) so
|
||||
// the failure log doesn't drown the file when PhotonServerSettings
|
||||
// isn't loaded yet.
|
||||
if (!PhotonPatches.OverrideApplied && (_photonOverridePollFrame++ % 6) == 0)
|
||||
PhotonPatches.TryApplyOverride(reason: "update-poll");
|
||||
|
||||
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
|
||||
|
||||
if (_diagnosticGameComplete) return;
|
||||
if (_diagnosticRetryFrame++ > 3600) return;
|
||||
@@ -632,41 +314,16 @@ public class Mod : MelonMod
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||
if (r.TryGetProperty("SingleOriginBaseUrl", out v)) Cfg.SingleOriginBaseUrl = (v.GetString() ?? "").TrimEnd('/');
|
||||
if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
|
||||
if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
|
||||
if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
|
||||
if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||
if (r.TryGetProperty("TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
|
||||
if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (r.TryGetProperty("DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
|
||||
if (r.TryGetProperty("DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||
if (r.TryGetProperty("DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||
if (r.TryGetProperty("DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||
if (r.TryGetProperty("QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||
if (r.TryGetProperty("QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
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("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
|
||||
if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
{
|
||||
var s = e.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
|
||||
}
|
||||
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
|
||||
}
|
||||
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}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
|
||||
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, SingleOrigin={(string.IsNullOrEmpty(Cfg.SingleOriginBaseUrl) ? "<off>" : Cfg.SingleOriginBaseUrl)}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
|
||||
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -677,8 +334,7 @@ public class Mod : MelonMod
|
||||
|
||||
// ── 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
|
||||
{
|
||||
@@ -688,12 +344,7 @@ 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,
|
||||
@@ -726,59 +377,7 @@ 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);
|
||||
}
|
||||
|
||||
private bool TryPatchDeclaredMethodByReturnType(string typeName, string returnTypeName,
|
||||
string fallbackMethodName,
|
||||
string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
var label = $"{typeName}.<returns {returnTypeName}>";
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
var matches = new List<MethodInfo>();
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters || method.IsSpecialName) continue;
|
||||
if (ReturnTypeMatches(method.ReturnType, returnTypeName)) matches.Add(method);
|
||||
}
|
||||
|
||||
if (matches.Count == 1)
|
||||
{
|
||||
var method = matches[0];
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {typeName}.{method.Name} (returns {returnTypeName})");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[patch-fail] {typeName}.{method.Name} (returns {returnTypeName}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.Count > 1)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var method in matches) names.Add(method.Name);
|
||||
Log.Warning($"[patch-ambiguous] {label}: {string.Join(", ", names)}; falling back to {fallbackMethodName}");
|
||||
}
|
||||
else if (logMiss)
|
||||
{
|
||||
Log.Warning($"[patch-miss] {label}; falling back to {fallbackMethodName}");
|
||||
}
|
||||
|
||||
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix);
|
||||
}
|
||||
|
||||
internal static Type? ResolveType(string name)
|
||||
@@ -853,15 +452,6 @@ public class Mod : MelonMod
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
private static bool ReturnTypeMatches(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed) return true;
|
||||
if (type.Name == requested || type.Name == prefixed) return true;
|
||||
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
||||
{
|
||||
if (args is null) return $"{typeName}.{methodName}";
|
||||
@@ -875,7 +465,7 @@ public class Mod : MelonMod
|
||||
{
|
||||
// Look in all three patch holder classes — small enough that a
|
||||
// linear scan is cheaper than per-class lookups.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||
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;
|
||||
@@ -902,24 +492,21 @@ public class Mod : MelonMod
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("BootSequence", "RegisterError", new[] { typeof(string) },
|
||||
prefix: nameof(DiagnosticPatches.BootSequenceRegisterError_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
// CheatManager fires these when its anti-cheat heuristics trip —
|
||||
// logging them surfaces "client thinks it's been tampered with",
|
||||
// a common cause of silent dorm-drops / kicks on a modded client.
|
||||
// Names verified in CheatManager.txt for 2020.12.18; the older
|
||||
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
|
||||
// there's no DeveloperFlag/StreamingAsset callback nor a
|
||||
// BootSequence.RegisterError sink in this build — those legacy
|
||||
// hooks were removed (they only ever logged [patch-miss] forever).
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"OnTimeCheatDetected",
|
||||
"OnObscuredTypeCheatDetected",
|
||||
"OnHeightCheatDetected",
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
"UnityTimeCheat",
|
||||
"ObscuredTypeCheat",
|
||||
"DeveloperFlagCheat",
|
||||
"HeightChangeCheat",
|
||||
"AdvancedMotionCheat",
|
||||
"StreamingAssetCheat"
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticByName("CheatManager", method,
|
||||
complete &= TryPatchDiagnosticByName("AnalyticsHelper", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
@@ -927,14 +514,15 @@ public class Mod : MelonMod
|
||||
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
|
||||
// pure-logging diagnostics that fired during Unity's very first
|
||||
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
|
||||
// methods) MonoMod's lazy CompileMethodHook on the trampoline
|
||||
// crashes the CLR with 0x80131506 the instant Awake JITs. The
|
||||
// hooks gave us no behavioral leverage — only a `[call] …` log
|
||||
// line — so they're disabled. If anyone needs Steam-side visibility
|
||||
// later, route it through the game's own log lines instead.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1155,543 +743,6 @@ internal static class DebugConsolePatches
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Dev-menu runtime probe (one-shot diagnostic) ────────────────────────
|
||||
// Answers stripped-vs-gated for the watch developer menu. With a real dev
|
||||
// account + forced flag the menu still doesn't appear, so the gate isn't the
|
||||
// account flag — either the native dev-menu prefab content is stripped from
|
||||
// this retail build, or a build-level flag skips its init. This logs the
|
||||
// actual runtime state once ~5s after load (no behaviour change) so we know
|
||||
// whether to un-gate the game's own dev menu or build our own watch entry.
|
||||
// Called from OnUpdate when Cfg.DiagnoseDevMenu is set; self-gates to run
|
||||
// once. Not a Harmony holder — invoked directly, so it's not registered in
|
||||
// GetPatchMethod.
|
||||
internal static class DevMenuProbe
|
||||
{
|
||||
private static int _frame;
|
||||
private static bool _done;
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
if (_done) return;
|
||||
_frame++;
|
||||
if (_frame < 120) return; // ~2s — let MelonLoader/game boot
|
||||
if ((_frame % 60) != 0) return; // then check ~once per second
|
||||
// Only fire once a REAL, in-scene HomeScreenFlow exists with its
|
||||
// UIBase wired (it gets assigned when the watch StackedUI initialises
|
||||
// in the dorm). The earlier version fired during boot on the loaded
|
||||
// PREFAB (Resources.FindObjectsOfTypeAll returns prefabs/inactive,
|
||||
// whose runtime UIBase is null) and, being one-shot, captured nothing
|
||||
// and never retried. NO timeout now — we wait until the watch is up.
|
||||
var homeType = Mod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? Mod.ResolveType("HomeScreenFlow");
|
||||
var home = homeType != null ? FindLiveHome(homeType) : null;
|
||||
if (home == null)
|
||||
{
|
||||
if ((_frame % 600) == 0)
|
||||
Mod.Log.Msg("[devmenu-probe] waiting for an in-scene HomeScreenFlow — enter your dorm / open the watch…");
|
||||
return;
|
||||
}
|
||||
_done = true;
|
||||
try { Run(homeType!, home); }
|
||||
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] failed: {ex}"); }
|
||||
}
|
||||
|
||||
// The live HomeScreenFlow (UIBase wired), skipping the prefab/inactive
|
||||
// copies FindObjectsOfTypeAll also returns.
|
||||
private static object? FindLiveHome(Type homeType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2 = ToIl2CppType(homeType);
|
||||
if (il2 is null) return null;
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2)?.Invoke(null, new[] { il2 });
|
||||
int n = ArrLen(all);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var o = ArrItem(all, i);
|
||||
if (o is null) continue;
|
||||
if (GetMemberValue(homeType, o, "UIBase", "get_UIBase") != null) return o;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Run(Type homeType, object homeInst)
|
||||
{
|
||||
Mod.Log.Msg("=== [devmenu-probe] runtime dev-UI state ===");
|
||||
ProbeType("RecRoom.Core.WatchUI", "WatchUI", DumpWatchUi);
|
||||
Mod.Log.Msg($"[devmenu-probe] HomeScreenFlow: type={homeType.FullName}, liveInstance=FOUND (UIBase wired)");
|
||||
try { DumpHomeScreen(homeType, homeInst); }
|
||||
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] HomeScreenFlow dump failed: {ex.Message}"); }
|
||||
ProbeType("RecRoom.Debugging.DebugConsole", "DebugConsole", DumpDebugConsole);
|
||||
Mod.Log.Msg("=== [devmenu-probe] done ===");
|
||||
}
|
||||
|
||||
private static void ProbeType(string fullName, string shortName, Action<Type, object?> dump)
|
||||
{
|
||||
var t = Mod.ResolveType(fullName) ?? Mod.ResolveType(shortName);
|
||||
if (t is null) { Mod.Log.Msg($"[devmenu-probe] {shortName}: TYPE NOT FOUND"); return; }
|
||||
var inst = FindOne(t);
|
||||
Mod.Log.Msg($"[devmenu-probe] {shortName}: type={t.FullName}, liveInstance={(inst is null ? "null" : "FOUND")}");
|
||||
try { dump(t, inst); } catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] {shortName} dump failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void DumpHomeScreen(Type t, object? inst)
|
||||
{
|
||||
if (inst is null) { Mod.Log.Msg(" (no HomeScreenFlow instance live yet — open the watch, then re-probe)"); return; }
|
||||
// Il2CppInterop exposes IL2CPP instance fields as PROPERTIES on the
|
||||
// managed proxy, so a plain AccessTools.Field misses them (the earlier
|
||||
// "field not found" was a false negative). Try property first, then
|
||||
// field. NULL => content stripped from the prefab (can't un-gate);
|
||||
// present => only gated.
|
||||
foreach (var name in new[] { "devMenuPanel", "devButtonPrefab", "reportBugButtonSpacer" })
|
||||
{
|
||||
var (found, mt, v) = GetMember(t, inst, name);
|
||||
if (!found) { Mod.Log.Msg($" {name}: <not found as property or field>"); continue; }
|
||||
var extra = "";
|
||||
if (v != null && name == "devMenuPanel")
|
||||
{
|
||||
try { var cc = v.GetType().GetProperty("childCount")?.GetValue(v); if (cc != null) extra = $" childCount={cc}"; } catch { }
|
||||
}
|
||||
Mod.Log.Msg($" {name} ({mt.Name}): {(v is null ? "NULL <-- stripped/unset" : "present")}{extra}");
|
||||
}
|
||||
// Calibration: are ordinary serialized field names exposed at all on
|
||||
// the runtime proxy? If 'profileButton' (a non-dev [SerializeField])
|
||||
// IS found but devMenuPanel isn't, the dev fields are genuinely absent
|
||||
// in this build. If profileButton is ALSO not found, Il2CppInterop
|
||||
// isn't exposing these fields by name (need a different access path).
|
||||
foreach (var cal in new[] { "profileButton", "settingsButton" })
|
||||
{
|
||||
var (cf, cmt, cv) = GetMember(t, inst, cal);
|
||||
Mod.Log.Msg($" [calib] {cal}: {(cf ? $"FOUND ({cmt.Name}, {(cv is null ? "null" : "present")})" : "not found")}");
|
||||
}
|
||||
// Named lookups miss → dump the proxy's actual member names to see
|
||||
// Il2CppInterop's scheme, then enumerate Button3D children directly
|
||||
// (no field names needed) — those are our clone sources for injection.
|
||||
var fs = t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var ps = t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var fn = new List<string>(); foreach (var f in fs) fn.Add(f.Name);
|
||||
var pn = new List<string>(); foreach (var p in ps) pn.Add(p.Name);
|
||||
Mod.Log.Msg($" [members] fields={fn.Count}, props={pn.Count}");
|
||||
if (fn.Count > 0) Mod.Log.Msg($" [fieldnames] {string.Join(", ", fn.GetRange(0, Math.Min(40, fn.Count)))}");
|
||||
if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}");
|
||||
DumpWatchButtons(t, inst);
|
||||
DumpCollections(t, inst, "HomeScreenFlow");
|
||||
DumpStackedUi(t, inst);
|
||||
}
|
||||
|
||||
// Walk HomeScreenFlow -> UIBase (StackedUIBase) and dump the data we need
|
||||
// to build a CUSTOM watch page by cloning a real flow:
|
||||
// * flowResources catalog — the TypeName of every PUSHABLE flow; these
|
||||
// are our clone-target candidates (pick a simple self-contained one).
|
||||
// * the Type->flow cache — what's already instantiated (do NOT mutate
|
||||
// these cached singletons; clone an independent copy instead).
|
||||
// * ScreenHeader presence — proves a cloned flow brings its own title
|
||||
// bar + back button for free.
|
||||
// Names are obfuscated, so we match by SHAPE (array-of-prefab-ref,
|
||||
// Dictionary<Type,flow>) rather than by field name.
|
||||
private static void DumpStackedUi(Type homeType, object homeInst)
|
||||
{
|
||||
var ui = GetMemberValue(homeType, homeInst, "UIBase", "get_UIBase");
|
||||
if (ui is null) { Mod.Log.Msg(" [stackedui] UIBase: null (could not reach StackedUIBase)"); return; }
|
||||
var uiType = ui.GetType();
|
||||
Mod.Log.Msg($" [stackedui] UIBase type={uiType.FullName}");
|
||||
|
||||
var hdr = GetMemberValue(homeType, homeInst, "ScreenHeader", "get_ScreenHeader");
|
||||
if (hdr != null)
|
||||
{
|
||||
var ht = hdr.GetType();
|
||||
var hp = new List<string>(); foreach (var p in ht.GetProperties()) hp.Add(p.Name);
|
||||
Mod.Log.Msg($" [stackedui] home ScreenHeader={ht.Name}; props={string.Join(",", hp.GetRange(0, Math.Min(30, hp.Count)))}");
|
||||
}
|
||||
else Mod.Log.Msg(" [stackedui] home ScreenHeader: null");
|
||||
|
||||
foreach (var f in uiType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
object? v; try { v = f.GetValue(ui); } catch { continue; }
|
||||
if (v is null) continue;
|
||||
int n = ArrLen(v);
|
||||
// Flow-prefab catalog: a non-empty array whose elements expose a
|
||||
// TypeName string (FlowPrefabResourceReference).
|
||||
if (n > 0 && n <= 300 && (v is Array || v.GetType().Name.IndexOf("Array", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
{
|
||||
var first = ArrItem(v, 0);
|
||||
var et = first?.GetType().Name ?? "?";
|
||||
if (et.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
et.IndexOf("Prefab", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
et.IndexOf("Resource", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
et.IndexOf("Reference", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
Mod.Log.Msg($" [stackedui] flow catalog '{f.Name}': {et}[{n}]");
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var e = ArrItem(v, i); if (e is null) continue;
|
||||
Mod.Log.Msg($" [{i}] {ReadStringMember(e)}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Flow cache: Dictionary<Type, StackedUIFlowBase>.
|
||||
var vt = v.GetType();
|
||||
if (vt.IsGenericType && vt.Name.StartsWith("Dictionary"))
|
||||
{
|
||||
var ga = vt.GetGenericArguments();
|
||||
if (ga.Length == 2 && ga[1].Name.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
var cp = vt.GetProperty("Count");
|
||||
Mod.Log.Msg($" [stackedui] flow cache '{f.Name}': Dictionary<{ga[0].Name},{ga[1].Name}> count={(cp?.GetValue(v) as int? ?? -1)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Property by name, else parameterless getter method, else null.
|
||||
private static object? GetMemberValue(Type t, object inst, string prop, string getter)
|
||||
{
|
||||
var (found, _, v) = GetMember(t, inst, prop);
|
||||
if (found && v != null) return v;
|
||||
var m = t.GetMethod(getter, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
|
||||
null, Type.EmptyTypes, null);
|
||||
if (m != null) { try { return m.Invoke(inst, null); } catch { } }
|
||||
return v;
|
||||
}
|
||||
|
||||
// First non-empty string field/property on an object — for obfuscated
|
||||
// wrappers, that's the TypeName of a FlowPrefabResourceReference.
|
||||
private static string ReadStringMember(object o)
|
||||
{
|
||||
var t = o.GetType();
|
||||
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
if (p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0)
|
||||
{ try { if (p.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
|
||||
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
if (f.FieldType == typeof(string))
|
||||
{ try { if (f.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
|
||||
return "(no string member)";
|
||||
}
|
||||
|
||||
// Enumerate Button3D components under the HomeScreenFlow via Unity's
|
||||
// GetComponentsInChildren(Type, includeInactive) — needs no field names.
|
||||
// Confirms we can grab native buttons to clone for the dev entry.
|
||||
private static void DumpWatchButtons(Type homeType, object homeInst)
|
||||
{
|
||||
var btnType = Mod.ResolveType("AGUI.StackedUI.Button3D");
|
||||
if (btnType is null) { Mod.Log.Msg(" [buttons] Button3D type NOT FOUND"); return; }
|
||||
var il2 = ToIl2CppType(btnType);
|
||||
if (il2 is null) { Mod.Log.Msg(" [buttons] Button3D il2cppType null"); return; }
|
||||
MethodInfo? m = null;
|
||||
foreach (var mi in homeType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (mi.Name != "GetComponentsInChildren") continue;
|
||||
var mp = mi.GetParameters();
|
||||
if (mp.Length == 2 && mp[0].ParameterType.IsInstanceOfType(il2) && mp[1].ParameterType == typeof(bool)) { m = mi; break; }
|
||||
}
|
||||
if (m is null) { Mod.Log.Msg(" [buttons] GetComponentsInChildren(Type,bool) not found"); return; }
|
||||
object? arr;
|
||||
try { arr = m.Invoke(homeInst, new object[] { il2, true }); }
|
||||
catch (Exception ex) { Mod.Log.Msg($" [buttons] GetComponentsInChildren threw: {(ex.InnerException ?? ex).Message}"); return; }
|
||||
int n = ArrLen(arr);
|
||||
Mod.Log.Msg($" [buttons] Button3D children: {n}");
|
||||
for (int i = 0; i < n && i < 20; i++)
|
||||
{
|
||||
var b = ArrItem(arr, i);
|
||||
var nm = b?.GetType().GetProperty("name")?.GetValue(b)?.ToString() ?? "?";
|
||||
Mod.Log.Msg($" [{i}] {nm}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ArrLen(object? arr)
|
||||
{
|
||||
if (arr is null) return 0;
|
||||
if (arr is Array a) return a.Length;
|
||||
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||
return (lp?.GetValue(arr) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static object? ArrItem(object? arr, int i)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.GetValue(i);
|
||||
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return gi?.Invoke(arr, new object[] { i });
|
||||
}
|
||||
|
||||
private static (bool found, Type type, object? val) GetMember(Type t, object inst, string name)
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
{ try { return (true, p.PropertyType, p.GetValue(inst)); } catch { } }
|
||||
var f = AccessTools.Field(t, name);
|
||||
if (f != null) { try { return (true, f.FieldType, f.GetValue(inst)); } catch { } }
|
||||
return (false, typeof(object), null);
|
||||
}
|
||||
|
||||
private static void DumpWatchUi(Type t, object? inst)
|
||||
{
|
||||
if (inst is null) { Mod.Log.Msg(" (no WatchUI instance live yet)"); return; }
|
||||
DumpCollections(t, inst, "WatchUI");
|
||||
}
|
||||
|
||||
private static void DumpDebugConsole(Type t, object? inst)
|
||||
{
|
||||
foreach (var name in new[] { "GJIKMPIMDHH", "IEGCNLHPKFH", "ShowButton", "ShowErrorsInHeadset", "ShowUnityDevelopmentConsole" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (p != null && p.CanRead) { Mod.Log.Msg($" static {name} = {p.GetValue(null)}"); continue; }
|
||||
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f != null) Mod.Log.Msg($" static {name} = {f.GetValue(null)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Msg($" {name}: read failed ({ex.Message})"); }
|
||||
}
|
||||
// Does the static command path work without the console UI in-scene?
|
||||
var exec = AccessTools.Method(t, "Execute", new[] { typeof(string) });
|
||||
if (exec is null) { Mod.Log.Msg(" Execute(string): <not found>"); return; }
|
||||
try { exec.Invoke(null, new object[] { "help" }); Mod.Log.Msg(" Execute(\"help\"): invoked OK — static command path is usable"); }
|
||||
catch (Exception ex) { var e = ex.InnerException ?? ex; Mod.Log.Msg($" Execute(\"help\"): threw {e.GetType().Name} ({e.Message})"); }
|
||||
}
|
||||
|
||||
// Log every array / List<> field whose element type mentions Flow/Watch,
|
||||
// with its count — surfaces the dev-flow list (devOnlyFlows-equivalent)
|
||||
// regardless of its obfuscated name, so we can see if dev flows exist.
|
||||
private static void DumpCollections(Type t, object inst, string label)
|
||||
{
|
||||
var members = new List<(string name, object? val)>();
|
||||
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{ try { members.Add((f.Name, f.GetValue(inst))); } catch { } }
|
||||
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{ if (p.CanRead && p.GetIndexParameters().Length == 0) { try { members.Add((p.Name, p.GetValue(inst))); } catch { } } }
|
||||
|
||||
foreach (var (name, v) in members)
|
||||
{
|
||||
if (v is null) continue;
|
||||
int? count = null; string elem = "";
|
||||
if (v is Array arr) { count = arr.Length; elem = v.GetType().GetElementType()?.Name ?? "?"; }
|
||||
else
|
||||
{
|
||||
var ft = v.GetType();
|
||||
if (ft.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(ft))
|
||||
{
|
||||
var cp = ft.GetProperty("Count");
|
||||
if (cp != null && cp.PropertyType == typeof(int))
|
||||
{
|
||||
count = (int?)cp.GetValue(v);
|
||||
var ga = ft.GetGenericArguments();
|
||||
elem = ga.Length > 0 ? ga[0].Name : "?";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count.HasValue &&
|
||||
(elem.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
elem.IndexOf("Watch", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
Mod.Log.Msg($" {label}.{name}: {elem}[{count}]");
|
||||
}
|
||||
}
|
||||
|
||||
// Under Il2CppInterop, UnityEngine.Object.FindObjectOfType takes an
|
||||
// Il2CppSystem.Type (NOT System.Type), so AccessTools.Method(...,
|
||||
// typeof(Type)) misses — that's the "FindObjectOfType(Type) not found"
|
||||
// in the log. Convert the managed Type to an Il2CppSystem.Type via
|
||||
// Il2CppType.From, then use Resources.FindObjectsOfTypeAll (which also
|
||||
// returns INACTIVE objects + loaded prefab instances — important, the
|
||||
// watch UI is usually inactive until opened). Falls back to the
|
||||
// active-only FindObjectOfType.
|
||||
private static object? FindOne(Type t)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppType = ToIl2CppType(t);
|
||||
if (il2cppType is null) return null;
|
||||
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType);
|
||||
var first = FirstOf(all?.Invoke(null, new[] { il2cppType }));
|
||||
if (first != null) return first;
|
||||
|
||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
|
||||
return findOne?.Invoke(null, new[] { il2cppType });
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static object? ToIl2CppType(Type managed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
|
||||
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
|
||||
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
|
||||
if (from is null) return null;
|
||||
var args = from.GetParameters().Length == 2
|
||||
? new object[] { managed, true } : new object[] { managed };
|
||||
return from.Invoke(null, args);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Find a public static method `name` whose single parameter accepts `arg`.
|
||||
private static MethodInfo? OneArgMethod(Type? t, string name, object arg)
|
||||
{
|
||||
if (t is null) return null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != name) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length == 1 && ps[0].ParameterType.IsInstanceOfType(arg)) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// First element of an Il2CppReferenceArray / Array / IList, or null.
|
||||
private static object? FirstOf(object? arr)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.Length > 0 ? a.GetValue(0) : null;
|
||||
var at = arr.GetType();
|
||||
var lenProp = at.GetProperty("Length") ?? at.GetProperty("Count");
|
||||
if (lenProp?.GetValue(arr) is int n && n > 0)
|
||||
{
|
||||
var getItem = at.GetMethod("get_Item", new[] { typeof(int) });
|
||||
if (getItem != null) return getItem.Invoke(arr, new object[] { 0 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Desktop Screen Sharing FPS override ─────────────────────────────────
|
||||
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts the
|
||||
// shared desktop image. Capture cadence is the baked [SerializeField]
|
||||
// float screenShareImageRefreshFrequency; the broadcast tick gates on
|
||||
// if (Time.time - lastSend >= 1f / screenShareImageRefreshFrequency)
|
||||
// (DesktopScreenSharingDisplay.txt:697-703) — so the field is literally the
|
||||
// FPS, with no runtime clamp (the [Range] is editor-only). We rewrite it in
|
||||
// an Awake postfix. The captured frame is transmitted via the component's
|
||||
// IPunObservable OnPhotonSerializeView, which only fires at
|
||||
// PhotonNetwork.SerializationRate (default 10 Hz), so we also lift the
|
||||
// Photon send/serialization rate to cover the target — otherwise a 30 fps
|
||||
// capture still only delivers ~10. Field names are un-obfuscated on both
|
||||
// builds (confirmed in the 2020.12.18 dump), so a plain AccessTools.Field
|
||||
// lookup works.
|
||||
internal static class ScreenSharePatches
|
||||
{
|
||||
private static bool _photonRateApplied;
|
||||
|
||||
public static void DisplayAwake_Postfix(object __instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fps = Mod.Cfg.DesktopScreenShareFps;
|
||||
if (fps <= 0f || __instance is null) return;
|
||||
var t = __instance.GetType();
|
||||
|
||||
SetField(t, __instance, "screenShareImageRefreshFrequency", fps);
|
||||
if (Mod.Cfg.DesktopScreenShareResolution > 0)
|
||||
SetField(t, __instance, "screenShareImageHorizontalResolution", Mod.Cfg.DesktopScreenShareResolution);
|
||||
if (Mod.Cfg.DesktopScreenShareQuality > 0)
|
||||
SetField(t, __instance, "screenShareImageCompressionQuality", Mod.Cfg.DesktopScreenShareQuality);
|
||||
|
||||
Mod.Log.Msg($"[screenshare] {t.Name}: refreshFrequency→{fps}fps" +
|
||||
(Mod.Cfg.DesktopScreenShareResolution > 0 ? $" res→{Mod.Cfg.DesktopScreenShareResolution}px" : "") +
|
||||
(Mod.Cfg.DesktopScreenShareQuality > 0 ? $" quality→{Mod.Cfg.DesktopScreenShareQuality}" : ""));
|
||||
|
||||
if (Mod.Cfg.DesktopScreenShareRaisePhotonRate && !_photonRateApplied)
|
||||
RaisePhotonRates((int)Math.Ceiling(fps));
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[screenshare] Awake postfix failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void SetField(Type t, object inst, string name, object value)
|
||||
{
|
||||
var f = AccessTools.Field(t, name);
|
||||
if (f is null) { Mod.Log.Warning($"[screenshare] field '{name}' not found on {t.Name}"); return; }
|
||||
f.SetValue(inst, value);
|
||||
}
|
||||
|
||||
// Lift PhotonNetwork.SendRate + SerializationRate to >= hz. PUN requires
|
||||
// SerializationRate <= SendRate, so SendRate is kept at the max of the
|
||||
// two. Both are public static int members (property on some PUN builds,
|
||||
// field on others) — handle either. GLOBAL: every PhotonView's
|
||||
// OnPhotonSerializeView now fires at this rate.
|
||||
private static void RaisePhotonRates(int hz)
|
||||
{
|
||||
var pn = Mod.ResolveType("PhotonNetwork");
|
||||
if (pn is null) { Mod.Log.Warning("[screenshare] PhotonNetwork not found; can't raise serialization rate (delivered FPS will stay ~10)"); return; }
|
||||
var send = ReadInt(pn, "SendRate");
|
||||
var ser = ReadInt(pn, "SerializationRate");
|
||||
var target = Math.Max(hz, 1);
|
||||
var newSend = Math.Max(target, Math.Max(send, ser));
|
||||
WriteInt(pn, "SendRate", newSend);
|
||||
WriteInt(pn, "SerializationRate", target);
|
||||
_photonRateApplied = true;
|
||||
Mod.Log.Msg($"[screenshare] Photon rates raised (GLOBAL): SendRate {send}→{newSend}, SerializationRate {ser}→{target}");
|
||||
}
|
||||
|
||||
private static int ReadInt(Type t, string name)
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (p is not null && p.CanRead && p.GetValue(null) is int pv) return pv;
|
||||
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (f is not null && f.GetValue(null) is int fv) return fv;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void WriteInt(Type t, string name, int value)
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (p is not null && p.CanWrite) { p.SetValue(null, value); return; }
|
||||
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (f is not null) f.SetValue(null, value);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MakerPenGiftPreviewPatches
|
||||
{
|
||||
private static bool _loggedMissingPointer;
|
||||
private static bool _loggedMissingRestrictionFlag;
|
||||
|
||||
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__instance is null) return true;
|
||||
var t = __instance.GetType();
|
||||
if (ReadField(__instance, t, "pointerLineRenderer") is null)
|
||||
{
|
||||
if (!_loggedMissingPointer)
|
||||
{
|
||||
_loggedMissingPointer = true;
|
||||
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
|
||||
{
|
||||
if (!_loggedMissingRestrictionFlag)
|
||||
{
|
||||
_loggedMissingRestrictionFlag = true;
|
||||
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object? ReadField(object instance, Type type, string name)
|
||||
{
|
||||
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
|
||||
return field?.GetValue(instance);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class UriPatches
|
||||
{
|
||||
public static void HttpManagerSendRequestString_Prefix(ref string url)
|
||||
@@ -1736,9 +787,22 @@ internal static class UriPatches
|
||||
private static void RewriteString(ref string uriString, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uriString)) return;
|
||||
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||
|
||||
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
|
||||
Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
|
||||
{
|
||||
var rewrittenUri = RewriteUri(uri);
|
||||
if (rewrittenUri is null) return;
|
||||
var rewrittenText = rewrittenUri.ToString();
|
||||
if (rewrittenText == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewrittenText}");
|
||||
uriString = rewrittenText;
|
||||
return;
|
||||
}
|
||||
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return;
|
||||
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||
var rewritten = uriString.Replace(".rec.net", "." + host);
|
||||
if (rewritten == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||
@@ -1754,30 +818,88 @@ 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;
|
||||
}
|
||||
|
||||
private static string CombinePath(params string[] parts)
|
||||
{
|
||||
var cleaned = new List<string>();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var p = (part ?? "").Trim('/');
|
||||
if (p.Length > 0) cleaned.Add(p);
|
||||
}
|
||||
return "/" + string.Join("/", cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PhotonPatches
|
||||
{
|
||||
/// <summary>True once <see cref="TryApplyOverride"/> has successfully
|
||||
/// written the user's AppId into <c>PhotonServerSettings</c>. The
|
||||
/// per-frame poller in <see cref="Mod.OnUpdate"/> stops calling once
|
||||
/// this flips true.</summary>
|
||||
public static bool OverrideApplied;
|
||||
|
||||
public static void PhotonAppIdOverride_Prefix()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return;
|
||||
// Kept as a belt-and-braces hook in case the watch's actual
|
||||
// connect path does happen to flow through ConnectUsingSettings
|
||||
// (it doesn't, in March's IL2CPP build — verified via log). The
|
||||
// poller in OnUpdate is what actually gets the override in
|
||||
// before the wire connect.
|
||||
TryApplyOverride(reason: "prefix");
|
||||
}
|
||||
|
||||
/// <summary>Tracks how many polling attempts we've logged so the
|
||||
/// "type not found yet" line doesn't drown the log file. Cleared
|
||||
/// once the override succeeds.</summary>
|
||||
private static int _missLogCount;
|
||||
|
||||
/// <summary>Mutates <see cref="PhotonServerSettings.AppID"/> +
|
||||
/// <c>VoiceAppID</c> + region with the values from
|
||||
/// <see cref="Mod.Cfg"/>. Returns true once the override has been
|
||||
/// written successfully; subsequent calls become no-ops. Returns
|
||||
/// false (and logs nothing on most retries) when PhotonServerSettings
|
||||
/// hasn't been loaded by the watch yet — the poller retries every
|
||||
/// frame until the ScriptableObject shows up.</summary>
|
||||
public static bool TryApplyOverride(string reason)
|
||||
{
|
||||
if (OverrideApplied) return true;
|
||||
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return false;
|
||||
try
|
||||
{
|
||||
// PhotonNetwork.PhotonServerSettings — accessed via reflection
|
||||
// so the type-name resolution stays in one place. Mutating the
|
||||
// ScriptableObject in-place is what the BepInEx plugin does
|
||||
// too; the Photon Connect path reads AppID / VoiceAppID off
|
||||
// this object immediately after.
|
||||
var photonNetwork = Mod.Instance is null ? null : FindGameType("PhotonNetwork");
|
||||
if (photonNetwork is null) return;
|
||||
// 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;
|
||||
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");
|
||||
@@ -1786,19 +908,9 @@ internal static class PhotonPatches
|
||||
var newId = Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
|
||||
|
||||
// Diagnostic: dump every readable AppSettings field before
|
||||
// we mutate, so a Photon Custom Auth failure on first connect
|
||||
// is debuggable from a single log dump. Lists every public
|
||||
// property + field on PhotonServerSettings (Server, Port,
|
||||
// NameServer, AuthMode, HostType, PreferredRegion, AppID,
|
||||
// VoiceAppID, BestRegionSummary, etc.) — covers both 2020.03
|
||||
// (Server/Port) and 2020.12 (AppSettings) layouts.
|
||||
Mod.Log.Msg("[photon-diag] PhotonServerSettings BEFORE override:");
|
||||
DumpSettings(settings, settingsType, indent: " ");
|
||||
|
||||
appIdProp?.SetValue(settings, newId);
|
||||
voiceProp?.SetValue(settings, newVoice);
|
||||
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice}");
|
||||
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"
|
||||
@@ -1820,8 +932,14 @@ internal static class PhotonPatches
|
||||
// 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;
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-appid] override failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
|
||||
@@ -1900,190 +1018,11 @@ internal static class PhotonPatches
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// AuthValuesInjector_Prefix parked 2026-05-28 — never wired to a
|
||||
// harmony hook in this branch anyway. See
|
||||
// attic/AuthValuesInjector.cs.attic.
|
||||
|
||||
/// <summary>Postfix on <c>PUNNetworkManager.FJOLIPKKIBE</c> — the
|
||||
/// 2020.12 watch's private AppSettings builder. <c>__result</c> is the
|
||||
/// <c>Photon.Realtime.AppSettings</c> it just built.
|
||||
///
|
||||
/// **CRITICAL OVERRIDE**: replaces <c>AppIdRealtime</c> and
|
||||
/// <c>AppIdVoice</c> with our configured values before the AppSettings
|
||||
/// reaches <c>PhotonNetwork.ConnectUsingSettings</c>. The 2020.12
|
||||
/// watch hardcodes Rec Room's official AppIds as IL2CPP string
|
||||
/// literals (verified at <c>PUNNetworkManager.txt:7205,7208</c>:
|
||||
/// <c>"9372aa8d-..."</c> and <c>"e93ae440-..."</c>). Patching
|
||||
/// resources.assets at offsets 0x7962480 / 0x79624ac doesn't help —
|
||||
/// those are unrelated leftover strings, NOT the AppIds the SDK
|
||||
/// actually reads. The only reliable swap point is here, after
|
||||
/// FJOLIPKKIBE constructs the AppSettings object but before the
|
||||
/// LoadBalancingClient consumes it.
|
||||
///
|
||||
/// Then dumps every readable field/property for diagnostics.</summary>
|
||||
public static void LogAppSettings_Postfix(object __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__result is null) { Mod.Log.Msg("[photon-diag] FJOLIPKKIBE returned null AppSettings"); return; }
|
||||
|
||||
// ── AppId injection ────────────────────────────────────────
|
||||
var t = __result.GetType();
|
||||
var newPun = string.IsNullOrEmpty(Mod.Cfg.PhotonAppId) ? null : Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId)
|
||||
? (newPun ?? null)
|
||||
: Mod.Cfg.PhotonVoiceAppId;
|
||||
if (newPun is not null)
|
||||
{
|
||||
var realtimeProp = t.GetProperty("AppIdRealtime") ?? t.GetProperty("AppIdFusion");
|
||||
var oldPun = realtimeProp?.GetValue(__result) as string;
|
||||
realtimeProp?.SetValue(__result, newPun);
|
||||
Mod.Log.Msg($"[photon-override] AppIdRealtime: {oldPun} → {newPun}");
|
||||
}
|
||||
if (newVoice is not null)
|
||||
{
|
||||
var voiceProp = t.GetProperty("AppIdVoice");
|
||||
var oldVoice = voiceProp?.GetValue(__result) as string;
|
||||
voiceProp?.SetValue(__result, newVoice);
|
||||
Mod.Log.Msg($"[photon-override] AppIdVoice: {oldVoice} → {newVoice}");
|
||||
}
|
||||
// Also force FixedRegion to the configured region so two clients
|
||||
// never end up on different regions of the same room name.
|
||||
if (!string.IsNullOrEmpty(Mod.Cfg.PhotonCloudRegion))
|
||||
{
|
||||
var fixedRegionProp = t.GetProperty("FixedRegion");
|
||||
if (fixedRegionProp is not null && fixedRegionProp.CanWrite)
|
||||
{
|
||||
fixedRegionProp.SetValue(__result, Mod.Cfg.PhotonCloudRegion);
|
||||
Mod.Log.Msg($"[photon-override] FixedRegion → {Mod.Cfg.PhotonCloudRegion}");
|
||||
}
|
||||
}
|
||||
|
||||
Mod.Log.Msg("[photon-diag] FJOLIPKKIBE built AppSettings (post-override):");
|
||||
DumpSettings(__result, t, indent: " ");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAppSettings/override failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Postfix on <c>PUNNetworkManager.EMGGKBALALH</c> — the
|
||||
/// 2020.12 watch's private AuthenticationValues builder. <c>__result</c>
|
||||
/// is the AuthValues it just built with environment + accessToken
|
||||
/// AuthGetParameters. Logs AuthType, UserId, and the full
|
||||
/// AuthGetParameters / AuthPostData payload that Photon Cloud will
|
||||
/// forward to the configured Custom Auth URL.</summary>
|
||||
public static void LogAuthValues_Postfix(object __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__result is null)
|
||||
{
|
||||
Mod.Log.Msg("[photon-diag] EMGGKBALALH returned null AuthValues (anonymous connect)");
|
||||
return;
|
||||
}
|
||||
var t = __result.GetType();
|
||||
string Read(string name) => t.GetProperty(name)?.GetValue(__result)?.ToString() ?? "<null>";
|
||||
var authType = Read("AuthType");
|
||||
var userId = Read("UserId");
|
||||
var getParams = t.GetProperty("AuthGetParameters")?.GetValue(__result)?.ToString() ?? "<null>";
|
||||
var postData = t.GetProperty("AuthPostData")?.GetValue(__result);
|
||||
var postStr = postData is byte[] bytes
|
||||
? $"<{bytes.Length} bytes: {System.Text.Encoding.UTF8.GetString(bytes)}>"
|
||||
: postData?.ToString() ?? "<null>";
|
||||
Mod.Log.Msg($"[photon-diag] EMGGKBALALH built AuthValues: AuthType={authType} UserId={userId}");
|
||||
Mod.Log.Msg($"[photon-diag] AuthGetParameters = {getParams}");
|
||||
Mod.Log.Msg($"[photon-diag] AuthPostData = {postStr}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAuthValues failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Prefix on <c>PUNNetworkManager.OnCustomAuthenticationFailed</c>
|
||||
/// — the watch's UI-error formatter only logs a truncated Unity-
|
||||
/// stringified version of the debugMessage. Capturing the raw
|
||||
/// string before the watch's handler runs gives us the FULL
|
||||
/// Photon error including the URL Photon Cloud actually tried to
|
||||
/// call (which is the smoking-gun answer to "is the dashboard URL
|
||||
/// saved or is Photon hitting a stale one").</summary>
|
||||
public static void OnCustomAuthenticationFailed_Prefix(object __0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var debugMessage = __0?.ToString() ?? "<null>";
|
||||
Mod.Log.Error("[photon-diag] OnCustomAuthenticationFailed FULL MESSAGE:");
|
||||
Mod.Log.Error(debugMessage);
|
||||
// Also dump current AppSettings — if Photon rejected the auth
|
||||
// we want to know what the watch's actual final config looked
|
||||
// like (post-override).
|
||||
var photonNetwork = FindGameType("PhotonNetwork");
|
||||
var settings = photonNetwork?.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (settings is not null)
|
||||
{
|
||||
Mod.Log.Error("[photon-diag] PhotonServerSettings at failure:");
|
||||
DumpSettings(settings, settings.GetType(), indent: " ");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] OnCustomAuthenticationFailed log failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Logs every public field + property on <paramref name="obj"/>
|
||||
/// — used by the photon-diag patches above to dump AppSettings in one
|
||||
/// pass without coupling to specific field names that differ across
|
||||
/// PUN versions (Server/Port vs AppSettings.NameServer in newer
|
||||
/// PUN 2.x, etc.).</summary>
|
||||
private static void DumpSettings(object obj, Type t, string indent)
|
||||
{
|
||||
// DeclaredOnly so we don't walk Il2CppObjectBase's properties
|
||||
// (ObjectClass, Pointer, WasCollected) — and, importantly, so
|
||||
// we don't reflect over any property whose getter internally
|
||||
// constructs a System.Uri. The Uri ctor is harmony-patched, so
|
||||
// the FIRST call to a Uri-returning getter triggers MonoMod's
|
||||
// lazy CompileMethodHook for the ctor, which fatal-CLRs
|
||||
// (0x80131506) on this build. The override has already run by
|
||||
// the time DumpSettings is called — the dump is diagnostic
|
||||
// only, so it's safer to stay scoped to user-land properties.
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
|
||||
try
|
||||
{
|
||||
foreach (var p in t.GetProperties(flags))
|
||||
{
|
||||
if (p.GetIndexParameters().Length > 0) continue;
|
||||
if (!p.CanRead) continue;
|
||||
// Defensively skip any property whose declared return
|
||||
// type is Uri (would still trigger ctor on .ToString()
|
||||
// path even if we tried to read it).
|
||||
if (p.PropertyType == typeof(Uri)) continue;
|
||||
object? v;
|
||||
try { v = p.GetValue(obj); } catch { continue; }
|
||||
if (v is Uri) continue;
|
||||
var str = v is null ? "<null>" : v.ToString();
|
||||
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
|
||||
{
|
||||
Mod.Log.Msg($"{indent}{p.Name} (nested):");
|
||||
DumpSettings(v, v.GetType(), indent + " ");
|
||||
continue;
|
||||
}
|
||||
Mod.Log.Msg($"{indent}{p.Name} = {str}");
|
||||
}
|
||||
foreach (var f in t.GetFields(flags))
|
||||
{
|
||||
if (f.FieldType == typeof(Uri)) continue;
|
||||
object? v;
|
||||
try { v = f.GetValue(obj); } catch { continue; }
|
||||
if (v is Uri) continue;
|
||||
var str = v is null ? "<null>" : v.ToString();
|
||||
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
|
||||
{
|
||||
Mod.Log.Msg($"{indent}{f.Name} (nested):");
|
||||
DumpSettings(v, v.GetType(), indent + " ");
|
||||
continue;
|
||||
}
|
||||
Mod.Log.Msg($"{indent}{f.Name} = {str}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] DumpSettings failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// GetCurrentLockToken parked alongside the injector — see
|
||||
// attic/AuthValuesInjector.cs.attic.
|
||||
// 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);
|
||||
}
|
||||
@@ -2375,6 +1314,12 @@ internal static class DiagnosticPatches
|
||||
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}");
|
||||
@@ -2449,218 +1394,3 @@ internal static class ChatPatches
|
||||
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
|
||||
}
|
||||
}
|
||||
|
||||
// Log-only diagnostics for the stuck first-dorm "Change Username?" dialog.
|
||||
// See the registration site in OnLateInitializeMelon and
|
||||
// Cfg.TraceRegistrationDialog. Nothing here changes game behaviour — every
|
||||
// hook only writes to the diagnostics log.
|
||||
internal static class RegistrationPatches
|
||||
{
|
||||
private static bool s_loggedFullyReg;
|
||||
|
||||
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
|
||||
// (does NOT modify) the return value. False ⇒ the dorm raises the
|
||||
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
|
||||
// stays readable (it's polled from several call sites every boot).
|
||||
public static void IsFullyRegistered_Postfix(bool __result)
|
||||
{
|
||||
if (!s_loggedFullyReg)
|
||||
{
|
||||
s_loggedFullyReg = true;
|
||||
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
|
||||
}
|
||||
}
|
||||
|
||||
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
|
||||
// the dialog-response handler (a dialog was shown). Names reach the
|
||||
// prefix via __originalMethod so we can tell which branch ran.
|
||||
public static void PromptLambda_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
|
||||
}
|
||||
|
||||
// ConfirmUIDialog button handlers. If one of these fires when the user
|
||||
// taps a dialog button but the modal stays open, the break is in the
|
||||
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
|
||||
// routing. "Okay" on the screenshot is Button_Affirmative.
|
||||
public static bool ButtonAffirmative_Prefix(object __instance)
|
||||
{
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
|
||||
|
||||
if (!IsRegistrationWarningDialog(__instance))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryResolveAffirmativeWithoutClose(__instance))
|
||||
{
|
||||
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
|
||||
return false;
|
||||
}
|
||||
|
||||
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ButtonNegative_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
|
||||
public static void ButtonNotNow_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
|
||||
public static void ButtonCancel_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
|
||||
|
||||
private static bool IsRegistrationWarningDialog(object instance)
|
||||
{
|
||||
var title = ReadTextField(instance, "titleText");
|
||||
var body = ReadTextField(instance, "bodyText");
|
||||
|
||||
return Contains(title, "Change Username")
|
||||
&& Contains(body, "permanently lost")
|
||||
&& Contains(body, "password");
|
||||
}
|
||||
|
||||
private static bool TryResolveAffirmativeWithoutClose(object instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
var respond = AccessTools.Method(instance.GetType(), "Respond");
|
||||
if (respond is null)
|
||||
{
|
||||
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
|
||||
}
|
||||
|
||||
if (respond is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameters = respond.GetParameters();
|
||||
if (parameters.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
|
||||
respond.Invoke(instance, new[] { response, (object)false });
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static object CreateAffirmativeResponse(Type responseType)
|
||||
{
|
||||
if (responseType.IsEnum)
|
||||
{
|
||||
return Enum.ToObject(responseType, 1);
|
||||
}
|
||||
|
||||
return Convert.ChangeType(1, responseType);
|
||||
}
|
||||
|
||||
private static string? ReadTextField(object instance, string fieldName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var field = AccessTools.Field(instance.GetType(), fieldName);
|
||||
var textObject = field?.GetValue(instance);
|
||||
if (textObject is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var property = AccessTools.Property(textObject.GetType(), "text");
|
||||
if (property?.GetValue(textObject) is string text)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
var getter = AccessTools.Method(textObject.GetType(), "get_text");
|
||||
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string fragment)
|
||||
{
|
||||
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Trace hooks for the watch's room-join flow. Registered by
|
||||
// Mod.RegisterJoinTracePatches — see that method for the verified call
|
||||
// chain. Everything here is log-only; no prefix returns false, so the
|
||||
// game's own join logic runs unchanged.
|
||||
internal static class JoinPatches
|
||||
{
|
||||
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
|
||||
// "Dorm Room" button. If this never fires, the button's UnityEvent
|
||||
// binding is the problem, not the join chain.
|
||||
public static void ButtonDormRoom_Prefix()
|
||||
{
|
||||
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
|
||||
}
|
||||
|
||||
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
|
||||
// values here: Harmony's `object[] __args` forces a trampoline that
|
||||
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
|
||||
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
|
||||
// when the boot-time dorm auto-join first calls this method. Logging
|
||||
// the overload's parameter *types* (pure reflection metadata, no value
|
||||
// boxing) is enough to tell which overload fired — the entry overload
|
||||
// (String,…) is the "DormRoom" press; the others are inner legs.
|
||||
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
|
||||
}
|
||||
|
||||
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
|
||||
// resolver that feeds the join. __0 is the single first param: a room
|
||||
// NAME string (String overload) or room ID (Int64 overload). Reading
|
||||
// it via `object __0` is safe — it's a string/long, not one of the
|
||||
// obfuscated ref-types that crashed `object[] __args`. This is the
|
||||
// value we need to compare boot (works) vs button (fails).
|
||||
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
|
||||
{
|
||||
var arg = __0?.ToString() ?? "<null>";
|
||||
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
|
||||
}
|
||||
|
||||
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
|
||||
// b__165_1 is the reject handler that raises the "An error occured /
|
||||
// contact recroom.happyfox.com" toast — so if THIS name appears, the
|
||||
// join failed; the success legs (other b__*) firing means it advanced.
|
||||
// (Same __args/0x80131506 constraint as above — name only.)
|
||||
public static void JoinCallback_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||
}
|
||||
|
||||
// Dedicated capture for the reject handler b__165_1(string error) —
|
||||
// the answer to "why did the join fail". Single string param, so
|
||||
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
|
||||
// is the string that becomes the happyfox toast.
|
||||
public static void JoinError_Prefix(object __0)
|
||||
{
|
||||
var err = __0?.ToString() ?? "<null>";
|
||||
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
|
||||
}
|
||||
|
||||
// Parameter-type list from reflection metadata only — never reads
|
||||
// instance values, so it can't trigger the MonoMod trampoline crash.
|
||||
private static string Sig(MethodBase m)
|
||||
{
|
||||
var ps = m.GetParameters();
|
||||
var names = new List<string>(ps.Length);
|
||||
foreach (var p in ps) names.Add(p.ParameterType.Name);
|
||||
return $"{ps.Length} args: {string.Join(",", names)}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
// Raise RRO quest party size past the baked 4 — precisely, only for the
|
||||
// allowlisted quests (Cfg.QuestMaxTeamSizeRooms).
|
||||
//
|
||||
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL):
|
||||
// GameConfigurationTool.Awake()
|
||||
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset, the
|
||||
// Unity ScriptableObject @0x58)
|
||||
// → calls GameConfigurationAsset.GDECPANDBHJ() ← builds the runtime
|
||||
// protobuf GameConfigurationData (PJPAPHKPKGM) FROM the asset
|
||||
// → stores it @0x88 and applies it (OAHJOLJOKGI) to the team manager.
|
||||
// Inside GDECPANDBHJ the build loop reads asset.TeamConfigurations[i]
|
||||
// .MaxTeamSize (a typed int struct-array: [arr+0x20 + i*4]) and writes each
|
||||
// straight into a new GameTeamConfigurationData.TeamSize (HFBCEJLLDLI @0x10).
|
||||
//
|
||||
// So the runtime cap originates from the asset's `TeamConfiguration[]` — a
|
||||
// READABLE, typed struct array — NOT from anything that needs the obfuscated
|
||||
// protobuf. Earlier attempts failed because:
|
||||
// • bumping the asset on a 2s poll happened AFTER GDECPANDBHJ already built &
|
||||
// cached the protobuf (timing, not the wrong field);
|
||||
// • patching the protobuf getters (get_FNJLAGHPMFL / get_FBOCAPLIIDH) is
|
||||
// impossible — Il2CppInterop doesn't emit the generic RepeatedField<T>
|
||||
// getter, and the simple int getter wasn't emitted either.
|
||||
//
|
||||
// THE FIX: Harmony-PREFIX GameConfigurationAsset.GDECPANDBHJ() and bump the
|
||||
// asset's TeamConfigurations[i].MaxTeamSize to the target IN PLACE, right
|
||||
// before it builds the protobuf. The built config then carries the new cap and
|
||||
// the team manager / pre-game roster / open-slots all honour it. Scoped by
|
||||
// asset.Name to the allowlist (PvP/sandbox configs are left alone).
|
||||
//
|
||||
// HOST-AUTHORITATIVE: the quest host builds the config it networks to joiners
|
||||
// (RpcDeserializeConfig), so the host must run this mod for everyone to see the
|
||||
// raised cap. (A joiner can't override — that would need the unreachable
|
||||
// protobuf path.)
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace DorkNet.ClientMod;
|
||||
|
||||
internal static class QuestTeamSize
|
||||
{
|
||||
private const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||
private static bool _loggedBump;
|
||||
private static bool _activeAllowedQuestConfig;
|
||||
private static int _teamIndexNormalizeLogCount;
|
||||
private static int _spawnModeDataNormalizeLogCount;
|
||||
private static int _requiredSpawnIndexNormalizeLogCount;
|
||||
private static int _spawnRelaxLogCount;
|
||||
|
||||
public static bool TryPatchSpawnIndexNormalizers(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
var ok = true;
|
||||
ok &= TryPatchTeamIndexNormalizer(harmony);
|
||||
ok &= TryPatchSpawnModeDataNormalizer(harmony);
|
||||
ok &= TryPatchRequiredSpawnIndexNormalizer(harmony);
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static bool TryPatchTeamIndexNormalizer(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var managerType = Mod.ResolveType("GameTeamManager");
|
||||
var indexType = Mod.ResolveType("CCMLKDOHKJE");
|
||||
if (managerType is null || indexType is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var target = AccessTools.Method(managerType, "GetTeamPlayerIndex");
|
||||
if (target is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex");
|
||||
return false;
|
||||
}
|
||||
|
||||
var patch = typeof(QuestTeamSize)
|
||||
.GetMethod(nameof(NormalizeTeamPlayerIndex_Postfix), BindingFlags.Public | BindingFlags.Static)!
|
||||
.MakeGenericMethod(indexType);
|
||||
harmony.Patch(target, postfix: new HarmonyMethod(patch));
|
||||
Mod.Log.Msg("[patch-ok] GameTeamManager.GetTeamPlayerIndex");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Error($"[patch-fail] GameTeamManager.GetTeamPlayerIndex: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryPatchSpawnModeDataNormalizer(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var spawnType = Mod.ResolveType("GameSpawnManager");
|
||||
var indexType = Mod.ResolveType("CCMLKDOHKJE");
|
||||
if (spawnType is null || indexType is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var target = AccessTools.Method(spawnType, "ALCADGJKKHJ");
|
||||
if (target is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ");
|
||||
return false;
|
||||
}
|
||||
|
||||
var patch = typeof(QuestTeamSize)
|
||||
.GetMethod(nameof(NormalizeSpawnModeData_Postfix), BindingFlags.Public | BindingFlags.Static)!
|
||||
.MakeGenericMethod(indexType);
|
||||
harmony.Patch(target, postfix: new HarmonyMethod(patch));
|
||||
Mod.Log.Msg("[patch-ok] GameSpawnManager.ALCADGJKKHJ");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Error($"[patch-fail] GameSpawnManager.ALCADGJKKHJ: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryPatchRequiredSpawnIndexNormalizer(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var spawnType = Mod.ResolveType("GameSpawnManager");
|
||||
var indexType = Mod.ResolveType("CCMLKDOHKJE");
|
||||
if (spawnType is null || indexType is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var target = AccessTools.Method(spawnType, "FKEICBPCMPJ");
|
||||
if (target is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer");
|
||||
return false;
|
||||
}
|
||||
|
||||
var patch = typeof(QuestTeamSize)
|
||||
.GetMethod(nameof(NormalizeRequiredSpawnIndex_Prefix), BindingFlags.Public | BindingFlags.Static)!
|
||||
.MakeGenericMethod(indexType);
|
||||
harmony.Patch(target, prefix: new HarmonyMethod(patch));
|
||||
Mod.Log.Msg("[patch-ok] GameSpawnManager.FKEICBPCMPJ index normalizer");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Error($"[patch-fail] GameSpawnManager.FKEICBPCMPJ index normalizer: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// POSTFIX on GameTeamManager.GetTeamPlayerIndex(Player). The RRO quest
|
||||
// scenes only have spawn points for indexes 0-3. After raising the quest
|
||||
// team cap, players 5+ legitimately receive INDEX_4+, but those values
|
||||
// cannot match any baked quest spawn point. Normalize just the enum result
|
||||
// that spawn code asks for; the actual team roster/cap remains raised.
|
||||
public static void NormalizeTeamPlayerIndex_Postfix<T>(ref T __result) where T : struct
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||
|
||||
var value = Convert.ToInt32(__result);
|
||||
if (!TryNormalizeIndex(value, out var normalized)) return;
|
||||
|
||||
__result = (T)Enum.ToObject(typeof(T), normalized);
|
||||
|
||||
if (_teamIndexNormalizeLogCount < 12)
|
||||
{
|
||||
_teamIndexNormalizeLogCount++;
|
||||
Mod.Log.Msg($"[questsize] normalized team-player index {value} -> {normalized} for quest spawn lookup " +
|
||||
$"activeQuest={_activeAllowedQuestConfig}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[questsize] team-player index normalize failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// POSTFIX on GameSpawnManager.ALCADGJKKHJ(..., out CCMLKDOHKJE index, ...).
|
||||
// FPBFJMMKPPJ calls this private helper immediately before FKEICBPCMPJ, so
|
||||
// this is the exact spawn-path value seen in the "TeamIndex INDEX_4" log.
|
||||
public static void NormalizeSpawnModeData_Postfix<T>(ref T __3) where T : struct
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||
|
||||
var value = Convert.ToInt32(__3);
|
||||
if (!TryNormalizeIndex(value, out var normalized)) return;
|
||||
|
||||
__3 = (T)Enum.ToObject(typeof(T), normalized);
|
||||
|
||||
if (_spawnModeDataNormalizeLogCount < 12)
|
||||
{
|
||||
_spawnModeDataNormalizeLogCount++;
|
||||
Mod.Log.Msg($"[questsize] normalized spawn-mode team-player index {value} -> {normalized} " +
|
||||
$"activeQuest={_activeAllowedQuestConfig}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[questsize] spawn-mode index normalize failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// PREFIX on GameSpawnManager.FKEICBPCMPJ(..., CCMLKDOHKJE requiredIndex, ...).
|
||||
// This is the last guard before the spawn point field compare at +0x88.
|
||||
public static void NormalizeRequiredSpawnIndex_Prefix<T>(ref T __2) where T : struct
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||
|
||||
var value = Convert.ToInt32(__2);
|
||||
if (!TryNormalizeIndex(value, out var normalized)) return;
|
||||
|
||||
__2 = (T)Enum.ToObject(typeof(T), normalized);
|
||||
|
||||
if (_requiredSpawnIndexNormalizeLogCount < 12)
|
||||
{
|
||||
_requiredSpawnIndexNormalizeLogCount++;
|
||||
Mod.Log.Msg($"[questsize] normalized required spawn index {value} -> {normalized} " +
|
||||
$"activeQuest={_activeAllowedQuestConfig}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[questsize] required spawn index normalize failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryNormalizeIndex(int value, out int normalized)
|
||||
{
|
||||
normalized = value % 4;
|
||||
return value >= 4;
|
||||
}
|
||||
|
||||
// PREFIX on GameConfigurationAsset.GDECPANDBHJ() (asset → protobuf builder).
|
||||
public static void Build_Prefix(object __instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = Mod.Cfg.QuestMaxTeamSize;
|
||||
if (target <= 0 || __instance is null) return;
|
||||
|
||||
var name = GetMember(__instance, "Name") as string ?? "";
|
||||
var allowed = IsAllowed(name);
|
||||
_activeAllowedQuestConfig = allowed;
|
||||
if (_seen.Add(name))
|
||||
Mod.Log.Msg($"[questsize] config build '{name}' allowed={allowed}");
|
||||
if (!allowed) return;
|
||||
|
||||
var arr = GetMember(__instance, "TeamConfigurations");
|
||||
int len = ArrLen(arr);
|
||||
if (len <= 0) return;
|
||||
|
||||
int bumped = 0;
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var elem = ArrItem(arr, i); // boxed TeamConfiguration struct (copy)
|
||||
if (elem is null) continue;
|
||||
int cur = ReadInt(elem, "MaxTeamSize");
|
||||
if (cur > 0 && cur < target &&
|
||||
WriteInt(elem, "MaxTeamSize", target) &&
|
||||
SetArrItem(arr!, i, elem)) // write the mutated struct back
|
||||
bumped++;
|
||||
}
|
||||
|
||||
if (bumped > 0 && !_loggedBump)
|
||||
{
|
||||
_loggedBump = true;
|
||||
Mod.Log.Msg($"[questsize] '{name}': bumped {bumped} team slot(s) → {target}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[questsize] build prefix failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// POSTFIX on GameSpawnManager.FKEICBPCMPJ(..., outputList). That method
|
||||
// filters spawn points by state/game-mode/spectator/team/team-player-index/
|
||||
// custom-tag before the spawn selector runs. The RRO quest prefabs only
|
||||
// have indexed start points for players 0-3; once we raise the team size,
|
||||
// player index 4+ can filter every point out before the built-in overlap
|
||||
// fallback has a chance to help.
|
||||
//
|
||||
// December decompile: FPBFJMMKPPJ calls this filter, then if output.Count
|
||||
// is still 0 it logs "due to spawn point requirement tests" and falls back
|
||||
// to the unfiltered source list as an arbitrary spawn set. Filling the
|
||||
// output list here keeps the normal overlap pass and spawn-selection mode
|
||||
// alive instead of taking that bad arbitrary fallback.
|
||||
//
|
||||
// Harmony binds __0 and __7 by argument position: source list and output
|
||||
// list. The obfuscated parameter names are not reliable in MelonLoader's
|
||||
// generated IL2CPP metadata, which is why this intentionally avoids them.
|
||||
// If the filtered output is empty, copy the already-selected candidate
|
||||
// scene spawn list back into the output so extra players reuse baked quest
|
||||
// spawn points instead of being left out in the void.
|
||||
public static void RelaxEmptySpawnFilter_Postfix(object __0, object __7)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||
if (Count(__7) > 0) return;
|
||||
|
||||
var sourceCount = Count(__0);
|
||||
if (sourceCount <= 0) return;
|
||||
|
||||
var copied = CopyListItems(__0, __7);
|
||||
if (_spawnRelaxLogCount < 12)
|
||||
{
|
||||
_spawnRelaxLogCount++;
|
||||
Mod.Log.Msg($"[questsize] relaxed empty quest spawn filter; activeQuest={_activeAllowedQuestConfig} " +
|
||||
$"source={sourceCount} copied={copied} " +
|
||||
$"sourceType={TypeName(__0)} outputType={TypeName(__7)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[questsize] spawn filter relax failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── allowlist matching (normalize: lowercase-alnum, strip filler) ──────
|
||||
private static List<string>? _wanted;
|
||||
|
||||
private static bool IsAllowed(string configName)
|
||||
{
|
||||
_wanted ??= BuildWanted();
|
||||
if (_wanted.Count == 0) return false;
|
||||
var have = Norm(configName);
|
||||
foreach (var w in _wanted)
|
||||
if (have.Contains(w)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<string> BuildWanted()
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var entry in Mod.Cfg.QuestMaxTeamSizeRooms)
|
||||
{
|
||||
var nrm = Norm(entry);
|
||||
if (nrm.Length > 0) list.Add(nrm);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static string Norm(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return "";
|
||||
var sb = new System.Text.StringBuilder(s.Length);
|
||||
foreach (var c in s)
|
||||
if (char.IsLetterOrDigit(c)) sb.Append(char.ToLowerInvariant(c));
|
||||
return sb.ToString().Replace("the", "").Replace("for", "").Replace("of", "");
|
||||
}
|
||||
|
||||
// ── reflection helpers (Il2CppInterop exposes il2cpp members as either a
|
||||
// property or a field depending on version — try both) ────────────────
|
||||
private static object? GetMember(object o, string name)
|
||||
{
|
||||
var t = o.GetType();
|
||||
var p = t.GetProperty(name, BF);
|
||||
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
{ try { return p.GetValue(o); } catch { } }
|
||||
var f = t.GetField(name, BF);
|
||||
if (f != null) { try { return f.GetValue(o); } catch { } }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ReadInt(object? o, string name)
|
||||
{
|
||||
if (o is null) return 0;
|
||||
var v = GetMember(o, name);
|
||||
if (v is int n) return n;
|
||||
return v != null && int.TryParse(v.ToString(), out var p) ? p : 0;
|
||||
}
|
||||
|
||||
private static bool WriteInt(object o, string name, int val)
|
||||
{
|
||||
var t = o.GetType();
|
||||
var p = t.GetProperty(name, BF);
|
||||
if (p != null && p.CanWrite) { try { p.SetValue(o, val); return true; } catch { } }
|
||||
var f = t.GetField(name, BF);
|
||||
if (f != null) { try { f.SetValue(o, val); return true; } catch { } }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int ArrLen(object? arr)
|
||||
{
|
||||
if (arr is null) return 0;
|
||||
if (arr is Array a) return a.Length;
|
||||
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||
return (lp?.GetValue(arr) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static int Count(object? collection) => ArrLen(collection);
|
||||
|
||||
private static object? ArrItem(object? arr, int i)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.GetValue(i);
|
||||
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return gi?.Invoke(arr, new object[] { i });
|
||||
}
|
||||
|
||||
// Write a (boxed) struct element back into an Il2CppStructArray via its
|
||||
// set_Item(int, T) indexer. Reflection unboxes to T on invoke.
|
||||
private static bool SetArrItem(object arr, int i, object val)
|
||||
{
|
||||
if (arr is Array a) { try { a.SetValue(val, i); return true; } catch { return false; } }
|
||||
foreach (var m in arr.GetType().GetMethods())
|
||||
{
|
||||
if (m.Name != "set_Item") continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length == 2 && ps[0].ParameterType == typeof(int))
|
||||
{
|
||||
try { m.Invoke(arr, new[] { i, val }); return true; } catch { }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int CopyListItems(object? src, object? dst)
|
||||
{
|
||||
if (src is null || dst is null) return 0;
|
||||
|
||||
MethodInfo? add = null;
|
||||
foreach (var method in dst.GetType().GetMethods())
|
||||
{
|
||||
if (method.Name != "Add") continue;
|
||||
if (method.GetParameters().Length != 1) continue;
|
||||
add = method;
|
||||
break;
|
||||
}
|
||||
if (add is null) return 0;
|
||||
|
||||
var n = ArrLen(src);
|
||||
var copied = 0;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var item = ArrItem(src, i);
|
||||
if (item is null) continue;
|
||||
try
|
||||
{
|
||||
add.Invoke(dst, new[] { item });
|
||||
copied++;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
private static string TypeName(object? value) =>
|
||||
value?.GetType().FullName ?? "<null>";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// AuthValuesInjector — PARKED 2026-05-28 (december-2020-12-18)
|
||||
// AuthValuesInjector — PARKED 2026-05-28
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
|
||||
@@ -8,41 +8,48 @@
|
||||
//
|
||||
// What this used to do
|
||||
// --------------------
|
||||
// Built a Photon AuthenticationValues with `userid` + `LoginLock`
|
||||
// custom-auth parameters from the in-game RecNet account state. The
|
||||
// server's /photon/customauth controller (PhotonCustomAuthController.cs)
|
||||
// validated those parameters against the player's /player/login lock.
|
||||
// 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.
|
||||
//
|
||||
// Status in this branch
|
||||
// ---------------------
|
||||
// The method existed but was **never actually wired to a harmony
|
||||
// hook** in this branch's OnLateInitializeMelon. December's Photon
|
||||
// AuthValues path goes through PhotonPatches.LogAuthValues_Postfix
|
||||
// (postfix on PUNNetworkManager.EMGGKBALALH), which only logs — it
|
||||
// doesn't inject. So this code was effectively dead in december.
|
||||
// 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.
|
||||
//
|
||||
// Removed because:
|
||||
// 1. Photon Cloud is currently configured with Custom Authentication
|
||||
// disabled at the AppId level (anonymous connects allowed), so
|
||||
// no server-side auth probe.
|
||||
// 2. Even when the watch did send AuthValues, the upstream Cloudflare
|
||||
// / Localtunnel layer 403'd Photon's auth probe before our
|
||||
// controller could decide anything.
|
||||
// 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).
|
||||
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the LoadConfig
|
||||
// line.
|
||||
// 3. Wire the harmony hook in OnLateInitializeMelon. The march branch's
|
||||
// pick of NetworkingPeer.CallAuthenticate doesn't fire in IL2CPP, so
|
||||
// consider hooking PUNNetworkManager.EMGGKBALALH postfix instead and
|
||||
// mutating its returned AuthValues in place — that's the mangled
|
||||
// method december verifies fires (LogAuthValues_Postfix already
|
||||
// proves this entry point works).
|
||||
// 4. Put `"InjectAuthValues": true` back in dorknet-clientmod.json.template.
|
||||
// 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.
|
||||
// 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;
|
||||
|
||||
@@ -85,6 +92,9 @@ internal static class AuthValuesInjector
|
||||
|
||||
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);
|
||||
@@ -125,8 +135,30 @@ internal static class AuthValuesInjector
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// FindGameType in the live Mod.cs is a delegate to Mod.ResolveType.
|
||||
// Use that on restore; this is just a placeholder to keep the file
|
||||
// compilable if someone untreats it without re-checking the wiring.
|
||||
private static Type? FindGameType(string name) => null;
|
||||
// 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}",
|
||||
"SingleOriginBaseUrl": "{SINGLE_ORIGIN_BASE_URL}",
|
||||
"PhotonAppId": "{PHOTON_APPID}",
|
||||
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
|
||||
"PhotonCloudRegion": "{PHOTON_REGION}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||
"QuestMaxTeamSize": 0,
|
||||
"QuestMaxTeamSizeRooms": [
|
||||
"CrimsonCauldron",
|
||||
"Crescendo",
|
||||
"GoldenTrophy",
|
||||
"IsleOfLostSkulls",
|
||||
"TheRiseofJumbotron"
|
||||
]
|
||||
"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,298 +0,0 @@
|
||||
namespace DorkNet.Contracts;
|
||||
|
||||
public static class DorkNetRouteOwnership
|
||||
{
|
||||
private const string AdminHost = "admin";
|
||||
|
||||
private static readonly DorkNetRouteGroup[] ServiceRouteGroups =
|
||||
[
|
||||
new(
|
||||
ServiceNames.Identity,
|
||||
["auth", "accounts"],
|
||||
[
|
||||
"/.well-known",
|
||||
"/account",
|
||||
"/api/account",
|
||||
"/api/auth",
|
||||
"/api/createaccount",
|
||||
"/api/platformlogin",
|
||||
"/api/playerreputation",
|
||||
"/api/players",
|
||||
"/api/relationships",
|
||||
"/api/settings",
|
||||
"/cachedlogin",
|
||||
"/connect",
|
||||
"/eac",
|
||||
"/parentalcontrol",
|
||||
"/photon",
|
||||
"/profileimage",
|
||||
"/settings",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Rooms,
|
||||
["rooms", "match", "discovery", "lists", "roomcomments", "roomieintegrations", "studio"],
|
||||
[
|
||||
"/api/activities",
|
||||
"/api/curatedroomplaylists",
|
||||
"/api/gameconfigs",
|
||||
"/api/gameconfigurations",
|
||||
"/api/gamesessions",
|
||||
"/api/leaderboard",
|
||||
"/api/playerelo",
|
||||
"/api/playlists",
|
||||
"/api/roomkeys",
|
||||
"/api/rooms",
|
||||
"/api/royale",
|
||||
"/discover",
|
||||
"/featuredrooms",
|
||||
"/goto",
|
||||
"/hot_rooms",
|
||||
"/hot_roomsandplaylists",
|
||||
"/invite",
|
||||
"/playlists",
|
||||
"/player",
|
||||
"/room",
|
||||
"/roominstance",
|
||||
"/rooms",
|
||||
"/roomsandplaylists",
|
||||
"/roomserver",
|
||||
"/search_rooms",
|
||||
"/search_roomsandplaylists",
|
||||
"/v1/find",
|
||||
"/v1/join",
|
||||
"/v1/leave",
|
||||
"/v1/session",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Notify,
|
||||
["notify", "chat", "platformnotifications"],
|
||||
[
|
||||
"/api/messages",
|
||||
"/api/notification",
|
||||
"/api/offlineinvite",
|
||||
"/hub",
|
||||
"/notify",
|
||||
"/thread",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Content,
|
||||
["cdn", "data", "img", "storage", "strings-cdn", "videos"],
|
||||
[
|
||||
"/api/images",
|
||||
"/api/photos",
|
||||
"/config",
|
||||
"/data",
|
||||
"/img",
|
||||
"/invention",
|
||||
"/upload",
|
||||
"/video",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Social,
|
||||
["clubs"],
|
||||
[
|
||||
"/announcements",
|
||||
"/api/announcement",
|
||||
"/api/clubreporting",
|
||||
"/api/communityboard",
|
||||
"/api/groups",
|
||||
"/api/playercheer",
|
||||
"/api/playerevents",
|
||||
"/api/playersubscriptions",
|
||||
"/club",
|
||||
"/members",
|
||||
"/subscription",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Commerce,
|
||||
["commerce", "econ"],
|
||||
[
|
||||
"/api/avatar",
|
||||
"/api/catalog",
|
||||
"/api/compatibility",
|
||||
"/api/consumables",
|
||||
"/api/equipment",
|
||||
"/api/gamerewards",
|
||||
"/api/inventions",
|
||||
"/api/purchase",
|
||||
"/api/storefronts",
|
||||
"/commerce",
|
||||
"/econ",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Platform,
|
||||
["cards", "cms", "datacollection", "gamelogs", "geo", "leaderboard", "link", "ns", "playersettings", "strings", "thorn"],
|
||||
[
|
||||
"/api/cards",
|
||||
"/api/config",
|
||||
"/api/events",
|
||||
"/api/quickplay",
|
||||
"/api/role",
|
||||
"/api/version",
|
||||
"/api/versioncheck",
|
||||
"/motd",
|
||||
"/pageview",
|
||||
"/ping",
|
||||
"/role",
|
||||
"/strings",
|
||||
"/v1/ping",
|
||||
"/v1/player",
|
||||
"/v1/regions",
|
||||
"/v1/services",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Moderation,
|
||||
["bugreporting", "moderation"],
|
||||
[
|
||||
"/api/admin",
|
||||
"/api/banappeal",
|
||||
"/api/bugreporting",
|
||||
"/api/moderation",
|
||||
"/api/playerreporting",
|
||||
"/api/playersbanned",
|
||||
"/api/sanitize",
|
||||
"/api/testcasemanagement",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Web,
|
||||
["www", AdminHost, "feed"],
|
||||
[
|
||||
"/api/site",
|
||||
],
|
||||
IncludesApexHost: true),
|
||||
];
|
||||
|
||||
public static IReadOnlyList<DorkNetRouteGroup> RouteGroups => ServiceRouteGroups;
|
||||
|
||||
public static 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,290 +0,0 @@
|
||||
using HarmonyLib;
|
||||
using Il2CppRecRoom.Debugging;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using UnityEngine;
|
||||
using UObject = UnityEngine.Object;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.DevMenu.DevMenuMod), "DorkNet DevMenu", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.DevMenu;
|
||||
|
||||
public class DevMenuMod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||
internal static DevMenuMod? Instance;
|
||||
internal HarmonyLib.Harmony PatchHarmony => HarmonyInstance;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
|
||||
public static class Cfg
|
||||
{
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
public static bool DiagnoseDevMenu = false;
|
||||
public static bool EnableDevWatchButton = false;
|
||||
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
|
||||
}
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet DevMenu loading ===");
|
||||
LoadConfig();
|
||||
|
||||
if (Cfg.EnableDebugConsole)
|
||||
{
|
||||
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
|
||||
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
|
||||
Log.Msg($"[debugconsole] enabled; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
|
||||
if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick();
|
||||
if (Cfg.EnableDevWatchButton) DevWatchButton.Tick();
|
||||
}
|
||||
|
||||
private static void LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
|
||||
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
Directory.CreateDirectory(userData);
|
||||
|
||||
var devPath = Path.Combine(userData, "dorknet-devmenu.json");
|
||||
var legacyPath = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
var path = File.Exists(devPath) ? devPath : legacyPath;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Msg($"[config] no dev config found; all dev menu features disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (r.TryGetProperty("EnableDebugConsole", out var v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||
if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
|
||||
if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
{
|
||||
var s = e.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
|
||||
}
|
||||
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
|
||||
}
|
||||
|
||||
Log.Msg($"[config] loaded {Path.GetFileName(path)}: debugConsole={Cfg.EnableDebugConsole}, devWatchButton={Cfg.EnableDevWatchButton}, diagnose={Cfg.DiagnoseDevMenu}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[config] load failed, using disabled defaults: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryPatchByName(string typeName, string methodName, Type[]? args = null, string? prefix = null, string? postfix = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning($"[patch-miss] {typeName}.{methodName}: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var method = args is null
|
||||
? AccessTools.Method(type, methodName)
|
||||
: AccessTools.Method(type, methodName, args);
|
||||
if (method is null)
|
||||
{
|
||||
Log.Warning($"[patch-miss] {typeName}.{methodName}: method not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
HarmonyMethod? pre = prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix));
|
||||
HarmonyMethod? post = postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix));
|
||||
Instance!.PatchHarmony.Patch(method, pre, post);
|
||||
Log.Msg($"[patch-ok] {typeName}.{methodName}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}.{methodName}: {ex.GetType().Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
foreach (var holder in new[] { typeof(DebugConsolePatches) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
}
|
||||
throw new MissingMethodException($"patch method '{name}' not found");
|
||||
}
|
||||
|
||||
internal static Type? ResolveType(string requested)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(requested, out var cached)) return cached;
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
Type? found = null;
|
||||
try
|
||||
{
|
||||
found = asm.GetType(requested, throwOnError: false)
|
||||
?? asm.GetType("Il2Cpp" + requested, throwOnError: false);
|
||||
if (found is null)
|
||||
{
|
||||
foreach (var t in asm.GetTypes())
|
||||
{
|
||||
if (t.FullName == requested ||
|
||||
t.Name == requested ||
|
||||
t.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
t.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true)
|
||||
{
|
||||
found = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (found is null) continue;
|
||||
ResolvedTypeCache[requested] = found;
|
||||
return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DebugConsolePatches
|
||||
{
|
||||
private static bool _consoleVisible;
|
||||
private static bool _warned;
|
||||
private static KeyCode _toggleKey = KeyCode.BackQuote;
|
||||
private static bool _keyParsed;
|
||||
private static MethodInfo? _toggleMethod;
|
||||
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_keyParsed)
|
||||
{
|
||||
_keyParsed = true;
|
||||
if (!Enum.TryParse(DevMenuMod.Cfg.DebugConsoleToggleKey, ignoreCase: true, out _toggleKey))
|
||||
{
|
||||
_toggleKey = KeyCode.BackQuote;
|
||||
DevMenuMod.Log.Warning($"[debugconsole] invalid key '{DevMenuMod.Cfg.DebugConsoleToggleKey}', defaulting to BackQuote");
|
||||
}
|
||||
}
|
||||
|
||||
if (Input.GetKeyDown(_toggleKey))
|
||||
{
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_warned)
|
||||
{
|
||||
_warned = true;
|
||||
DevMenuMod.Log.Warning($"[debugconsole] poll failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Toggle()
|
||||
{
|
||||
try
|
||||
{
|
||||
var console = UObject.FindObjectOfType<DebugConsole>();
|
||||
if (console == null)
|
||||
{
|
||||
DevMenuMod.Log.Warning("[debugconsole] no live DebugConsole in scene");
|
||||
return;
|
||||
}
|
||||
|
||||
_toggleMethod ??= AccessTools.Method(typeof(DebugConsole), "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(typeof(DebugConsole), "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(typeof(DebugConsole));
|
||||
if (_toggleMethod is null)
|
||||
{
|
||||
DevMenuMod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
return;
|
||||
}
|
||||
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(console, new object[] { _consoleVisible });
|
||||
DevMenuMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DevMenuMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static MethodInfo? FindToggleByShape(Type t)
|
||||
{
|
||||
MethodInfo? found = null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||
if (found is not null) return null;
|
||||
found = m;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DevMenuProbe
|
||||
{
|
||||
private static int _frame;
|
||||
private static bool _done;
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
if (_done) return;
|
||||
_frame++;
|
||||
if (_frame < 180) return;
|
||||
_done = true;
|
||||
|
||||
var homeType = DevMenuMod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? DevMenuMod.ResolveType("HomeScreenFlow");
|
||||
var consoleType = DevMenuMod.ResolveType("RecRoom.Debugging.DebugConsole") ?? typeof(DebugConsole);
|
||||
DevMenuMod.Log.Msg("=== [devmenu-probe] runtime dev-menu state ===");
|
||||
DevMenuMod.Log.Msg($"[devmenu-probe] HomeScreenFlow type: {(homeType is null ? "NOT FOUND" : homeType.FullName)}");
|
||||
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole type: {(consoleType is null ? "NOT FOUND" : consoleType.FullName)}");
|
||||
try
|
||||
{
|
||||
var console = UObject.FindObjectOfType<DebugConsole>();
|
||||
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole live instance: {(console == null ? "null" : "FOUND")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DevMenuMod.Log.Warning($"[devmenu-probe] console instance probe failed: {ex.Message}");
|
||||
}
|
||||
DevMenuMod.Log.Msg("=== [devmenu-probe] done ===");
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// Native watch "Dev Console" injection — typed against the per-install Il2Cpp
|
||||
// interop assemblies (referenced in the csproj, dev-tools only, never shipped).
|
||||
// Pure reflection couldn't drive this build's watch UI (FindObjectOfType /
|
||||
// property-get / GetComponentsInChildren all failed under Il2CppInterop), so
|
||||
// we use real types here: clone an existing watch Button3D, relabel it, and
|
||||
// wire its OnClick to run console commands via DebugConsole.Execute (which
|
||||
// works headless even though the console UI isn't in the scene). It's the
|
||||
// game's own Button3D, so it's interactable with the VR laser AND the
|
||||
// Screen-Mode mouse. Gated behind Cfg.EnableDevWatchButton.
|
||||
using System;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppAGUI.StackedUI;
|
||||
using Il2CppRecRoom.Debugging;
|
||||
using Il2CppTMPro;
|
||||
using UnityEngine.Events;
|
||||
using UObject = UnityEngine.Object;
|
||||
|
||||
namespace DorkNet.DevMenu;
|
||||
|
||||
internal static class DevWatchButton
|
||||
{
|
||||
private static int _frame;
|
||||
private static bool _done;
|
||||
|
||||
// Polled from Mod.OnUpdate while Cfg.EnableDevWatchButton is set. Waits for
|
||||
// the watch's HomeScreenFlow to exist, then injects once.
|
||||
public static void Tick()
|
||||
{
|
||||
if (_done) return;
|
||||
_frame++;
|
||||
if (_frame < 120 || (_frame % 30) != 0) return; // settle, then ~2/s
|
||||
var home = UObject.FindObjectOfType<HomeScreenFlow>();
|
||||
if (home == null && _frame < 3600) return; // wait for the watch
|
||||
_done = true;
|
||||
if (home == null) { DevMenuMod.Log.Warning("[devbtn] HomeScreenFlow never appeared"); return; }
|
||||
try { Inject(home); }
|
||||
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] inject failed: {ex}"); }
|
||||
}
|
||||
|
||||
private static void Inject(HomeScreenFlow home)
|
||||
{
|
||||
var buttons = home.GetComponentsInChildren<Button3D>(true);
|
||||
if (buttons == null || buttons.Length == 0)
|
||||
{
|
||||
DevMenuMod.Log.Warning("[devbtn] no Button3D found under HomeScreenFlow");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer a source button that HAS a TMP label, so our clones can be
|
||||
// relabeled (HomeButton etc. are icon-only — no text child).
|
||||
Button3D? src = null;
|
||||
for (int i = 0; i < buttons.Length; i++)
|
||||
if (buttons[i].GetComponentInChildren<TextMeshProUGUI>(true) != null) { src = buttons[i]; break; }
|
||||
bool labeled = src != null;
|
||||
if (src == null) src = buttons[0]; // fall back: inject anyway, just no text
|
||||
DevMenuMod.Log.Msg($"[devbtn] {buttons.Length} watch buttons; clone source='{src.name}' labeled={labeled}");
|
||||
|
||||
int n = 0;
|
||||
foreach (var (label, cmd) in ParsePresets())
|
||||
{
|
||||
var clone = UObject.Instantiate(src, src.transform.parent);
|
||||
clone.name = $"DorkNetDev_{label}";
|
||||
clone.gameObject.SetActive(true);
|
||||
clone.transform.localScale = src.transform.localScale;
|
||||
|
||||
var tmp = clone.GetComponentInChildren<TextMeshProUGUI>(true);
|
||||
if (tmp != null) tmp.text = label;
|
||||
|
||||
var command = cmd; // capture per-button
|
||||
clone.OnClick.RemoveAllListeners();
|
||||
clone.OnClick.AddListener(DelegateSupport.ConvertDelegate<UnityAction>((Action)(() => Run(label, command))));
|
||||
_clones.Add(clone);
|
||||
n++;
|
||||
}
|
||||
DevMenuMod.Log.Msg($"[devbtn] injected {n} dev command button(s) into the watch bar — open the watch to use them");
|
||||
DumpCommands();
|
||||
}
|
||||
|
||||
// Log the real console command names from DebugConsoleCommandConfig.Metas
|
||||
// so we can set DevCommands correctly (the asset loads even though the
|
||||
// console component isn't in the scene).
|
||||
private static void DumpCommands()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfgs = UObject.FindObjectsOfTypeAll(Il2CppType.Of<DebugConsoleCommandConfig>());
|
||||
DevMenuMod.Log.Msg($"[devcmds] {cfgs.Length} DebugConsoleCommandConfig asset(s) loaded");
|
||||
for (int c = 0; c < cfgs.Length; c++)
|
||||
{
|
||||
var cfg = cfgs[c].TryCast<DebugConsoleCommandConfig>();
|
||||
var metas = cfg?.Metas;
|
||||
if (metas == null) continue;
|
||||
DevMenuMod.Log.Msg($"[devcmds] {metas.Count} command(s):");
|
||||
for (int i = 0; i < metas.Count; i++)
|
||||
{
|
||||
var m = metas[i];
|
||||
DevMenuMod.Log.Msg($" {m.MethodName} ({m.DeclaringType})");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { DevMenuMod.Log.Warning($"[devcmds] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void Run(string label, string command)
|
||||
{
|
||||
DevMenuMod.Log.Msg($"[devbtn] '{label}' -> DebugConsole.Execute(\"{command}\")");
|
||||
try { DebugConsole.Execute(command); }
|
||||
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] Execute('{command}') failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Presets come from Cfg.DevCommands as "Label=command" strings.
|
||||
private static System.Collections.Generic.IEnumerable<(string label, string cmd)> ParsePresets()
|
||||
{
|
||||
foreach (var entry in DevMenuMod.Cfg.DevCommands)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||
var eq = entry.IndexOf('=');
|
||||
if (eq > 0) yield return (entry.Substring(0, eq).Trim(), entry.Substring(eq + 1).Trim());
|
||||
else yield return (entry.Trim(), entry.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Generic.List<Button3D> _clones = new();
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.DevMenu</RootNamespace>
|
||||
<AssemblyName>DorkNet.DevMenu</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
|
||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
|
||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Unzip SourceFiles="$(MelonLoaderZip)"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
OverwriteReadOnlyFiles="true" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Il2CppAsmDir Condition="'$(Il2CppAsmDir)' == ''">C:\Users\Alexa\Downloads\DorkRoom-2020.12\MelonLoader\Il2CppAssemblies</Il2CppAsmDir>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition="Exists('$(Il2CppAsmDir)')">
|
||||
<Reference Include="Assembly-CSharp"><HintPath>$(Il2CppAsmDir)\Assembly-CSharp.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Il2Cppmscorlib"><HintPath>$(Il2CppAsmDir)\Il2Cppmscorlib.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine.CoreModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine.UI"><HintPath>$(Il2CppAsmDir)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine.InputLegacyModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.InputLegacyModule.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Unity.TextMeshPro"><HintPath>$(Il2CppAsmDir)\Unity.TextMeshPro.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Il2CppRecRoom.Promises.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Promises.Runtime.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Il2CppRecRoom.Datastructures.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Datastructures.Runtime.dll</HintPath><Private>false</Private></Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"EnableDebugConsole": false,
|
||||
"DebugConsoleToggleKey": "BackQuote",
|
||||
"DiagnoseDevMenu": false,
|
||||
"EnableDevWatchButton": false,
|
||||
"DevCommands": [
|
||||
"Help=help",
|
||||
"Fly=fly",
|
||||
"NoClip=noclip"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
// ── June-2018 (build 20180621_EA) login wire shapes ──────────────────────────
|
||||
//
|
||||
// The 2018 client speaks a DIFFERENT login protocol than the 2019/2020 OAuth
|
||||
// flow the rest of DorkNet implements:
|
||||
// • POST api/platformlogin/v1/{loginaccount,logincached,createaccount}
|
||||
// • request body is a JSON object (Unity JsonUtility / LitJson), NOT form
|
||||
// • response is the "login envelope" below, read by RecNet.cs:40014-40021
|
||||
// via case-sensitive LitJson keys.
|
||||
//
|
||||
// Keys verified from the assembly's REAL (name-preserved) Deserialize methods
|
||||
// via dnlib — the obfuscated-name sibling methods are Beebyte decoys with
|
||||
// scrambled literals (see project_2018_mono_obfuscation memory).
|
||||
|
||||
/// <summary>
|
||||
/// Login response envelope. RecNet login-response Deserialize (RecNet.cs:40016-40020)
|
||||
/// reads: Error (req string), Player (optional object), Token (req string),
|
||||
/// FirstLoginOfTheDay (req bool), AnalyticsSessionId (req long). Empty Error
|
||||
/// means success; a non-empty Error is shown to the user and aborts login.
|
||||
/// </summary>
|
||||
public class Login2018Response
|
||||
{
|
||||
[JsonPropertyName("Error")]
|
||||
public string Error { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Player")]
|
||||
public Player2018? Player { get; set; }
|
||||
|
||||
[JsonPropertyName("Token")]
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("FirstLoginOfTheDay")]
|
||||
public bool FirstLoginOfTheDay { get; set; }
|
||||
|
||||
[JsonPropertyName("AnalyticsSessionId")]
|
||||
public long AnalyticsSessionId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2018 player object embedded in the login envelope and returned bare from
|
||||
/// getcachedlogins. Keys + required/optional verified from RecNet.cs:64632-64667
|
||||
/// (RecNet.EEBPLECPEGD.Deserialize):
|
||||
/// REQUIRED (Util.GetKey throws if missing): Id, Username, DisplayName, XP,
|
||||
/// Level, RegistrationStatus, Developer, CanReceiveInvites, ProfileImageName,
|
||||
/// JuniorProfile, ForceJuniorImages, PendingJunior, HasBirthday.
|
||||
/// OPTIONAL: Bio (TryGetKey), AvoidJuniors (ContainsKey-guarded),
|
||||
/// PlayerReputation/PlatformId (GetObjectKey -> null ok; omitted here),
|
||||
/// GroupMemberships (ContainsKey-guarded; omitted).
|
||||
/// NOTE the 2018 client uses "Id" (not "AccountId") and "Developer" (not
|
||||
/// "IsDeveloper") — distinct from the 2020 PlayerProfile DTO.
|
||||
/// </summary>
|
||||
public class Player2018
|
||||
{
|
||||
[JsonPropertyName("Id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("Username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("DisplayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Bio")]
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("XP")]
|
||||
public int XP { get; set; }
|
||||
|
||||
[JsonPropertyName("Level")]
|
||||
public int Level { get; set; } = 1;
|
||||
|
||||
// Registration step enum (ProfileSelectionManager.NFCBDCFLBOC):
|
||||
// invalid=-1, video=0, birthday=1, email=2, COMPLETE=10. Anything < 10
|
||||
// makes the client run the signup step flow (it got "stuck on birthday").
|
||||
// 10 = fully registered → client goes straight in-game.
|
||||
[JsonPropertyName("RegistrationStatus")]
|
||||
public int RegistrationStatus { get; set; } = 10;
|
||||
|
||||
[JsonPropertyName("Developer")]
|
||||
public bool Developer { get; set; }
|
||||
|
||||
[JsonPropertyName("CanReceiveInvites")]
|
||||
public bool CanReceiveInvites { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("ProfileImageName")]
|
||||
public string ProfileImageName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("JuniorProfile")]
|
||||
public bool JuniorProfile { get; set; }
|
||||
|
||||
[JsonPropertyName("ForceJuniorImages")]
|
||||
public bool ForceJuniorImages { get; set; }
|
||||
|
||||
[JsonPropertyName("PendingJunior")]
|
||||
public bool PendingJunior { get; set; }
|
||||
|
||||
[JsonPropertyName("HasBirthday")]
|
||||
public bool HasBirthday { get; set; }
|
||||
|
||||
[JsonPropertyName("AvoidJuniors")]
|
||||
public bool AvoidJuniors { get; set; }
|
||||
|
||||
// REQUIRED to be non-null: the post-login analytics Identify dereferences
|
||||
// localPlayer.PlayerReputation.<field> (RecNet.cs ~NECCOFKHCAA) — a null
|
||||
// here NREs the boot coroutine and the client hangs on splash_screen.
|
||||
[JsonPropertyName("PlayerReputation")]
|
||||
public PlayerReputation2018 PlayerReputation { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2018 PlayerReputation (RecNet.HOLNFPNKNGB.Deserialize) — all int. Keys exact
|
||||
/// incl. the "Noteriety" misspelling. Must be present (non-null) on the player.
|
||||
/// </summary>
|
||||
public class PlayerReputation2018
|
||||
{
|
||||
[JsonPropertyName("Noteriety")] public int Noteriety { get; set; }
|
||||
[JsonPropertyName("CheerGeneral")] public int CheerGeneral { get; set; }
|
||||
[JsonPropertyName("CheerHelpful")] public int CheerHelpful { get; set; }
|
||||
[JsonPropertyName("CheerGreatHost")] public int CheerGreatHost { get; set; }
|
||||
[JsonPropertyName("CheerSportsman")] public int CheerSportsman { get; set; }
|
||||
[JsonPropertyName("CheerCreative")] public int CheerCreative { get; set; }
|
||||
[JsonPropertyName("CheerCredit")] public int CheerCredit { get; set; }
|
||||
[JsonPropertyName("SubscriberCount")] public int SubscriberCount { get; set; }
|
||||
[JsonPropertyName("SubscribedCount")] public int SubscribedCount { get; set; }
|
||||
[JsonPropertyName("SelectedCheer")] public int SelectedCheer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic 2018 success envelope {Success, Message} (RecNet EFDOILDKBFK) used by
|
||||
/// registeraccount (email attach) and other simple POSTs. Note the key is
|
||||
/// "Message" (not "error").
|
||||
/// </summary>
|
||||
public class SuccessMessage2018
|
||||
{
|
||||
[JsonPropertyName("Success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("Message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -50,13 +50,34 @@ public class RecRoomConfig
|
||||
[JsonPropertyName("DailyObjectives")]
|
||||
public List<List<DailyObjective>> DailyObjectives { get; set; } = DailyObjective.DefaultSets();
|
||||
|
||||
[JsonPropertyName("ConfigTable")]
|
||||
// 2018.06 client (RecNet.OPOHNGAOCCD.Deserialize, RecNet.cs:9062-9068) reads
|
||||
// ConfigTable as an ARRAY of {Key,Value} objects, NOT a JSON object map:
|
||||
// foreach elem in dict["ConfigTable"]: table[elem["Key"]] = elem["Value"]
|
||||
// The 2020 client read it as an object — but this branch targets 2018, so we
|
||||
// emit the array form. Keep the settable Dictionary for existing call sites
|
||||
// (ConfigService.GetConfig populates it) and project to the wire array.
|
||||
[JsonIgnore]
|
||||
public Dictionary<string, string> ConfigTable { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("ConfigTable")]
|
||||
public List<ConfigTableEntry> ConfigTableWire =>
|
||||
ConfigTable.Select(kv => new ConfigTableEntry { Key = kv.Key, Value = kv.Value }).ToList();
|
||||
|
||||
[JsonPropertyName("ServiceUrls")]
|
||||
public Dictionary<string, string> ServiceUrls { get; set; } = [];
|
||||
}
|
||||
|
||||
// 2018 ConfigTable element shape: {"Key": "...", "Value": "..."} (PascalCase,
|
||||
// LitJson Util.GetKey<string>("Key"/"Value")).
|
||||
public class ConfigTableEntry
|
||||
{
|
||||
[JsonPropertyName("Key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class PhotonConfig
|
||||
{
|
||||
[JsonPropertyName("AppId")]
|
||||
@@ -77,11 +98,17 @@ public class PhotonConfig
|
||||
|
||||
public class MatchmakingParams
|
||||
{
|
||||
[JsonPropertyName("MaxPlayersPerRoom")]
|
||||
public int MaxPlayersPerRoom { get; set; } = 8;
|
||||
// 2018 RecNet.LOIBFONMHMF.Deserialize reads exactly TWO required float keys
|
||||
// via Util.GetKey<Single> (verified via dnlib). It's fetched as an optional
|
||||
// object (GetObjectKey) inside config/v2, but once PRESENT the client
|
||||
// deserializes it — wrong/missing keys throw KeyNotFoundException →
|
||||
// "Received malformed RecNet response" → "Failed to connect to RecNet" at
|
||||
// boot. The 2020 MaxPlayersPerRoom/MinPlayersToStart keys are NOT read here.
|
||||
[JsonPropertyName("PreferFullRoomsFrequency")]
|
||||
public float PreferFullRoomsFrequency { get; set; } = 0.5f;
|
||||
|
||||
[JsonPropertyName("MinPlayersToStart")]
|
||||
public int MinPlayersToStart { get; set; } = 1;
|
||||
[JsonPropertyName("PreferEmptyRoomsFrequency")]
|
||||
public float PreferEmptyRoomsFrequency { get; set; } = 0.5f;
|
||||
}
|
||||
|
||||
public class LevelProgressionEntry
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
/// <summary>
|
||||
/// June-2018 presence record returned from POST api/presence/v2/list.
|
||||
/// Keys verified from RecNet.DBHHFILLFLC.Deserialize: PlayerId (long),
|
||||
/// IsOnline (bool), GameSession (optional object — null when the player is
|
||||
/// online but not in a session; the client null-checks it).
|
||||
/// </summary>
|
||||
public class Presence2018
|
||||
{
|
||||
[JsonPropertyName("PlayerId")]
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[JsonPropertyName("IsOnline")]
|
||||
public bool IsOnline { get; set; }
|
||||
|
||||
[JsonPropertyName("GameSession")]
|
||||
public object? GameSession { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Rooms;
|
||||
|
||||
/// <summary>
|
||||
/// Flat June-2018 room object. The 2018 client (RecNet Room.Deserialize,
|
||||
/// RecNet.cs:78244) reads these keys directly off the room JSON — there is NO
|
||||
/// 2020-style RoomDetails/Scenes wrapper; DataBlobName + PersonalDetails are
|
||||
/// embedded right here. Required keys throw (Util.GetKey) if missing, so every
|
||||
/// field below must be present except the genuinely-optional ones noted.
|
||||
///
|
||||
/// Notes:
|
||||
/// • CreatorPlayerId is read as an int client-side; all account ids fit int.
|
||||
/// • Date keys are ISO-8601 (client DateTimeParse.ParseISO8601). Use real
|
||||
/// dates, never year-9999 (overflow — see WireDates).
|
||||
/// • ActivityLevelId parse failure is a non-fatal warning client-side, so ""
|
||||
/// is safe for non-activity rooms.
|
||||
/// • CoOwners/Hosts are int arrays (empty ok); PersonalDetails is an optional
|
||||
/// object (null ok).
|
||||
/// </summary>
|
||||
public class Room2018
|
||||
{
|
||||
[JsonPropertyName("RoomId")] public long RoomId { get; set; }
|
||||
[JsonPropertyName("Name")] public string Name { get; set; } = string.Empty;
|
||||
[JsonPropertyName("Description")] public string Description { get; set; } = string.Empty;
|
||||
[JsonPropertyName("CreatorPlayerId")] public long CreatorPlayerId { get; set; }
|
||||
[JsonPropertyName("ImageName")] public string ImageName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("DataBlobName")] public string DataBlobName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("ActivityLevelId")] public string ActivityLevelId { get; set; } = string.Empty;
|
||||
[JsonPropertyName("IsSandbox")] public bool IsSandbox { get; set; }
|
||||
[JsonPropertyName("Instanced")] public bool Instanced { get; set; } = true;
|
||||
[JsonPropertyName("MaxPlayers")] public int MaxPlayers { get; set; } = 20;
|
||||
[JsonPropertyName("Accessibility")] public int Accessibility { get; set; } = 1;
|
||||
[JsonPropertyName("AccessibilityLocked")] public bool AccessibilityLocked { get; set; }
|
||||
[JsonPropertyName("VisitorCount")] public int VisitorCount { get; set; }
|
||||
[JsonPropertyName("VisitCount")] public int VisitCount { get; set; }
|
||||
[JsonPropertyName("CheerCount")] public int CheerCount { get; set; }
|
||||
[JsonPropertyName("ReportCount")] public int ReportCount { get; set; }
|
||||
[JsonPropertyName("State")] public int State { get; set; }
|
||||
[JsonPropertyName("StateModifiedAt")] public DateTime StateModifiedAt { get; set; }
|
||||
[JsonPropertyName("CreatedAt")] public DateTime CreatedAt { get; set; }
|
||||
[JsonPropertyName("ModifiedAt")] public DateTime ModifiedAt { get; set; }
|
||||
[JsonPropertyName("LastVisitedAt")] public DateTime LastVisitedAt { get; set; }
|
||||
[JsonPropertyName("DataModifiedAt")] public DateTime DataModifiedAt { get; set; }
|
||||
[JsonPropertyName("CoOwners")] public List<int> CoOwners { get; set; } = [];
|
||||
[JsonPropertyName("Hosts")] public List<int> Hosts { get; set; } = [];
|
||||
[JsonPropertyName("PersonalDetails")] public object? PersonalDetails { get; set; }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,90 +0,0 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.HostFiltering;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private const string JwtSecret = "endpoint-contract-secret-000000000000000000000000000000000000000000";
|
||||
private readonly string _dataRoot;
|
||||
|
||||
public DorkNetServerFactory()
|
||||
{
|
||||
_dataRoot = Path.Combine(Path.GetTempPath(), "dorknet-server-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dataRoot);
|
||||
}
|
||||
|
||||
public string ApexDomain => "dork.test";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.UseSetting("Domain:Apex", ApexDomain);
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AllowedHosts"] = "*",
|
||||
["Database:Provider"] = "sqlite",
|
||||
["Database:SqlitePath"] = Path.Combine(_dataRoot, "dorknet-test.db"),
|
||||
["Domain:Apex"] = ApexDomain,
|
||||
["Jwt:Secret"] = JwtSecret,
|
||||
["S3:Endpoint"] = "",
|
||||
["S3:AccessKey"] = "",
|
||||
["S3:SecretKey"] = "",
|
||||
["DorkNet:DefaultClientVersion"] = "december_2020_12_18",
|
||||
["DorkNet:SupportedVersions:0"] = "december_2020_12_18",
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.PostConfigure<HostFilteringOptions>(options =>
|
||||
{
|
||||
options.AllowedHosts =
|
||||
[
|
||||
ApexDomain,
|
||||
$"*.{ApexDomain}",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
];
|
||||
options.AllowEmptyHosts = true;
|
||||
options.IncludeFailureMessage = true;
|
||||
});
|
||||
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "https://api.rec.net/",
|
||||
ValidateAudience = false,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_dataRoot))
|
||||
{
|
||||
Directory.Delete(_dataRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Test cleanup must not hide the original test failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public static partial class EndpointContractDiscovery
|
||||
{
|
||||
public static IReadOnlyList<EndpointContract> Discover()
|
||||
{
|
||||
var serverAssembly = typeof(Program).Assembly;
|
||||
var controllerBaseType = typeof(ControllerBase);
|
||||
return serverAssembly.GetTypes()
|
||||
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t))
|
||||
.SelectMany(DiscoverControllerRoutes)
|
||||
.OrderBy(route => route.Method, StringComparer.Ordinal)
|
||||
.ThenBy(route => route.SamplePath, StringComparer.Ordinal)
|
||||
.ThenBy(route => route.Source, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<EndpointContract> DiscoverControllerRoutes(Type controller)
|
||||
{
|
||||
var controllerName = ControllerName(controller);
|
||||
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
|
||||
.OfType<IRouteTemplateProvider>()
|
||||
.Select(a => a.Template)
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Cast<string>()
|
||||
.DefaultIfEmpty("")
|
||||
.ToArray();
|
||||
|
||||
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
var httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<HttpMethodAttribute>()
|
||||
.ToArray();
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attr in httpAttrs)
|
||||
{
|
||||
var attrMethods = attr.HttpMethods.Any()
|
||||
? attr.HttpMethods
|
||||
: new[] { "ANY" };
|
||||
|
||||
foreach (var httpMethod in attrMethods)
|
||||
{
|
||||
foreach (var controllerTemplate in controllerRouteTemplates)
|
||||
{
|
||||
var template = CombineTemplates(controllerTemplate, attr.Template);
|
||||
template = ReplaceRouteTokens(template, controllerName, method.Name);
|
||||
yield return new EndpointContract(
|
||||
httpMethod.ToUpperInvariant(),
|
||||
template,
|
||||
ToSamplePath(template),
|
||||
$"{controller.FullName}.{method.Name}",
|
||||
RequiresAuthorization(controller, method),
|
||||
IsAdminEndpoint(controller, method),
|
||||
ConsumesContentTypes(controller, method));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RequiresAuthorization(Type controller, MethodInfo method)
|
||||
{
|
||||
var attributes = controller.GetCustomAttributes(inherit: true)
|
||||
.Concat(method.GetCustomAttributes(inherit: true))
|
||||
.ToArray();
|
||||
|
||||
if (attributes.OfType<IAllowAnonymous>().Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return attributes.OfType<IAuthorizeData>().Any();
|
||||
}
|
||||
|
||||
private static bool IsAdminEndpoint(Type controller, MethodInfo method)
|
||||
{
|
||||
return controller.FullName?.Contains(".Controllers.Admin.", StringComparison.Ordinal) == true
|
||||
|| method.Name.Contains("Admin", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string[] ConsumesContentTypes(Type controller, MethodInfo method)
|
||||
{
|
||||
var consumes = method.GetCustomAttributes(inherit: true)
|
||||
.Concat(controller.GetCustomAttributes(inherit: true))
|
||||
.OfType<ConsumesAttribute>()
|
||||
.SelectMany(a => a.ContentTypes)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return consumes.Length == 0
|
||||
? ["application/json"]
|
||||
: consumes;
|
||||
}
|
||||
|
||||
private static string ControllerName(Type controller)
|
||||
{
|
||||
const string suffix = "Controller";
|
||||
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
|
||||
? controller.Name[..^suffix.Length]
|
||||
: controller.Name;
|
||||
}
|
||||
|
||||
private static string CombineTemplates(string? controllerTemplate, string? actionTemplate)
|
||||
{
|
||||
controllerTemplate ??= "";
|
||||
actionTemplate ??= "";
|
||||
|
||||
if (actionTemplate.StartsWith("~/", StringComparison.Ordinal))
|
||||
{
|
||||
return "/" + actionTemplate[2..].TrimStart('/');
|
||||
}
|
||||
|
||||
if (actionTemplate.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
return "/" + actionTemplate.TrimStart('/');
|
||||
}
|
||||
|
||||
var left = controllerTemplate.Trim('/');
|
||||
var right = actionTemplate.Trim('/');
|
||||
|
||||
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(left))
|
||||
{
|
||||
return "/" + right;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(right))
|
||||
{
|
||||
return "/" + left;
|
||||
}
|
||||
|
||||
return "/" + left + "/" + right;
|
||||
}
|
||||
|
||||
private static string ReplaceRouteTokens(string template, string controllerName, string actionName)
|
||||
{
|
||||
return template
|
||||
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ToSamplePath(string template)
|
||||
{
|
||||
var path = RouteParameterRegex().Replace(template, match =>
|
||||
{
|
||||
var name = match.Groups["name"].Value.ToLowerInvariant();
|
||||
var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
|
||||
|
||||
if (name.Contains("playerid") || name.Contains("accountid") || name == "id")
|
||||
{
|
||||
return EndpointContract.TestPlayerToken;
|
||||
}
|
||||
|
||||
if (constraint.Contains("long") || constraint.Contains("int") || name.EndsWith("id"))
|
||||
{
|
||||
return "1";
|
||||
}
|
||||
|
||||
if (constraint.Contains("bool"))
|
||||
{
|
||||
return "true";
|
||||
}
|
||||
|
||||
if (constraint.Contains("guid"))
|
||||
{
|
||||
return "00000000-0000-0000-0000-000000000001";
|
||||
}
|
||||
|
||||
if (name.Contains("roomname"))
|
||||
{
|
||||
return "DormRoom";
|
||||
}
|
||||
|
||||
if (name.Contains("locale"))
|
||||
{
|
||||
return "en";
|
||||
}
|
||||
|
||||
return "sample";
|
||||
});
|
||||
|
||||
path = Regex.Replace(path, "/+", "/");
|
||||
return path.StartsWith('/') ? path : "/" + path;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
|
||||
private static partial Regex RouteParameterRegex();
|
||||
}
|
||||
|
||||
public sealed record EndpointContract(
|
||||
string Method,
|
||||
string Template,
|
||||
string SamplePath,
|
||||
string Source,
|
||||
bool RequiresAuthorization,
|
||||
bool IsAdminEndpoint,
|
||||
string[] Consumes)
|
||||
{
|
||||
public const string TestPlayerToken = "__test_player_id__";
|
||||
|
||||
public string PathFor(GameClientSession session)
|
||||
{
|
||||
return SamplePath.Replace(
|
||||
TestPlayerToken,
|
||||
session.PlayerId.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class GameClientEndpointContractTests :
|
||||
IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public GameClientEndpointContractTests(DorkNetServerFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Game_client_contract_probe_reaches_every_controller_route()
|
||||
{
|
||||
var routes = EndpointContractDiscovery.Discover();
|
||||
Assert.NotEmpty(routes);
|
||||
|
||||
using var client = _factory.CreateClient(new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
});
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var route in routes)
|
||||
{
|
||||
var result = await ProbeAsync(client, route, session, _factory.ApexDomain);
|
||||
if (!result.Passed)
|
||||
{
|
||||
failures.Add(
|
||||
$"{route.Method,-6} {result.Host}{result.Path} {route.Source} expected {result.Expected}, got {result.Observed}"
|
||||
+ (string.IsNullOrWhiteSpace(result.ResponseExcerpt) ? "" : $" body={result.ResponseExcerpt}"));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
failures.Count == 0,
|
||||
$"Endpoint contract failures ({failures.Count}/{routes.Count}):{Environment.NewLine}"
|
||||
+ string.Join(Environment.NewLine, failures));
|
||||
}
|
||||
|
||||
private static async Task<EndpointProbeResult> ProbeAsync(
|
||||
HttpClient client,
|
||||
EndpointContract route,
|
||||
GameClientSession session,
|
||||
string apexDomain)
|
||||
{
|
||||
var path = route.PathFor(session);
|
||||
path = AddClientQuery(path, route, session);
|
||||
var host = HostFor(path, apexDomain);
|
||||
using var request = new HttpRequestMessage(
|
||||
ToHttpMethod(route.Method),
|
||||
new Uri($"http://{host}{path}"));
|
||||
request.Headers.Accept.ParseAdd("application/json");
|
||||
request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
if (NeedsRequestBody(request.Method))
|
||||
{
|
||||
request.Content = CreateContent(route, session);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.SendAsync(request);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var passed = ResponseMatchesContract(route, response.StatusCode);
|
||||
return new EndpointProbeResult(
|
||||
route.Method,
|
||||
host,
|
||||
path,
|
||||
ExpectedContract(route),
|
||||
statusCode,
|
||||
null,
|
||||
Excerpt(responseText),
|
||||
passed);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
return new EndpointProbeResult(
|
||||
route.Method,
|
||||
host,
|
||||
path,
|
||||
ExpectedContract(route),
|
||||
null,
|
||||
ex.Message,
|
||||
null,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ResponseMatchesContract(
|
||||
EndpointContract route,
|
||||
HttpStatusCode statusCode)
|
||||
{
|
||||
if ((int)statusCode >= 500)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (statusCode is HttpStatusCode.MethodNotAllowed
|
||||
or HttpStatusCode.NotImplemented
|
||||
or HttpStatusCode.UpgradeRequired)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (route.RequiresAuthorization
|
||||
&& statusCode is HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ExpectedContract(EndpointContract route)
|
||||
{
|
||||
if (route.IsAdminEndpoint)
|
||||
{
|
||||
return "admin endpoint may reject non-admin test player, but must not 5xx/405/426";
|
||||
}
|
||||
|
||||
if (route.RequiresAuthorization)
|
||||
{
|
||||
return "authenticated test player should reach the action without 401/5xx/405/426; 403 is valid for owner/admin-only resources";
|
||||
}
|
||||
|
||||
return "game-client request should not 5xx/405/426";
|
||||
}
|
||||
|
||||
private static HttpMethod ToHttpMethod(string method)
|
||||
{
|
||||
return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase)
|
||||
? HttpMethod.Get
|
||||
: new HttpMethod(method);
|
||||
}
|
||||
|
||||
private static bool NeedsRequestBody(HttpMethod method)
|
||||
{
|
||||
return method == HttpMethod.Post
|
||||
|| method == HttpMethod.Put
|
||||
|| method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HttpContent CreateContent(
|
||||
EndpointContract route,
|
||||
GameClientSession session)
|
||||
{
|
||||
var consumes = route.Consumes.Select(c => c.ToLowerInvariant()).ToArray();
|
||||
if (consumes.Any(c => c.Contains("application/x-www-form-urlencoded")))
|
||||
{
|
||||
return new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["account_id"] = session.PlayerId.ToString(),
|
||||
["AccountId"] = session.PlayerId.ToString(),
|
||||
["clubId"] = "1",
|
||||
["ClubId"] = "1",
|
||||
["description"] = "Endpoint contract description",
|
||||
["deviceId"] = session.DeviceId,
|
||||
["grant_type"] = "password",
|
||||
["image"] = "endpoint-contract.png",
|
||||
["imageName"] = "endpoint-contract.png",
|
||||
["fileName"] = "endpoint-contract.png",
|
||||
["message"] = "Endpoint contract message",
|
||||
["name"] = "EndpointContract",
|
||||
["newPassword"] = "endpoint-contract-password",
|
||||
["oldPassword"] = "",
|
||||
["password"] = "endpoint-contract-password",
|
||||
["ping"] = "42",
|
||||
["platformId"] = session.PlatformId,
|
||||
["playerId"] = session.PlayerId.ToString(),
|
||||
["playerIds"] = session.PlayerId.ToString(),
|
||||
["query"] = "DormRoom",
|
||||
["region"] = "us",
|
||||
["targetPlayerId"] = session.PlayerId.ToString(),
|
||||
["text"] = "Endpoint contract message",
|
||||
["username"] = "EndpointContract",
|
||||
["device_id"] = session.DeviceId,
|
||||
["platform"] = "0",
|
||||
["platform_id"] = session.PlatformId,
|
||||
["Ids"] = session.PlayerId.ToString(),
|
||||
["PlayerIds"] = session.PlayerId.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (consumes.Any(c => c.Contains("multipart/form-data")))
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
content.Add(new StringContent("endpoint-contract"), "name");
|
||||
content.Add(new StringContent(session.PlayerId.ToString()), "playerId");
|
||||
content.Add(new StringContent("endpoint-contract.png"), "imageName");
|
||||
content.Add(new StringContent("endpoint-contract.png"), "fileName");
|
||||
content.Add(new ByteArrayContent([]), "file", "endpoint-contract.bin");
|
||||
return content;
|
||||
}
|
||||
|
||||
if (consumes.Any(c => c.Contains("text/plain")))
|
||||
{
|
||||
return new StringContent("", Encoding.UTF8, "text/plain");
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
PlayerId = session.PlayerId,
|
||||
AccountId = session.PlayerId,
|
||||
ClubId = 1,
|
||||
Description = "Endpoint contract description",
|
||||
DeviceId = session.DeviceId,
|
||||
FileName = "endpoint-contract.png",
|
||||
Id = session.PlayerId,
|
||||
Image = "endpoint-contract.png",
|
||||
ImageName = "endpoint-contract.png",
|
||||
Message = "Endpoint contract message",
|
||||
Platform = 0,
|
||||
PlatformId = session.PlatformId,
|
||||
PlayerIds = new[] { session.PlayerId },
|
||||
Name = "EndpointContract",
|
||||
Password = "endpoint-contract-password",
|
||||
Query = "DormRoom",
|
||||
RoomName = "DormRoom",
|
||||
TargetPlayerId = session.PlayerId,
|
||||
Text = "Endpoint contract message",
|
||||
});
|
||||
return new StringContent(json, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
private static string AddClientQuery(
|
||||
string path,
|
||||
EndpointContract route,
|
||||
GameClientSession session)
|
||||
{
|
||||
var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?";
|
||||
var lower = path.ToLowerInvariant();
|
||||
var query = new List<string>();
|
||||
|
||||
if (lower.Contains("search", StringComparison.Ordinal)
|
||||
|| lower.Contains("query", StringComparison.Ordinal))
|
||||
{
|
||||
query.Add("query=EndpointContract");
|
||||
}
|
||||
|
||||
if (lower == "/members/bulk")
|
||||
{
|
||||
query.Add("clubId=1");
|
||||
query.Add($"playerIds={session.PlayerId}");
|
||||
}
|
||||
|
||||
if (lower == "/player/photonregionpings")
|
||||
{
|
||||
query.Add("region=us");
|
||||
query.Add("ping=42");
|
||||
}
|
||||
|
||||
if (route.Source.Contains("CachedLogins", StringComparison.Ordinal)
|
||||
|| lower.Contains("platformlogin/cached", StringComparison.Ordinal)
|
||||
|| lower.Contains("savedlogins", StringComparison.Ordinal))
|
||||
{
|
||||
query.Add("platform=0");
|
||||
query.Add($"platformId={Uri.EscapeDataString(session.PlatformId)}");
|
||||
}
|
||||
|
||||
if (route.Source.Contains("Bulk", StringComparison.Ordinal)
|
||||
&& !query.Any(q => q.StartsWith("Ids=", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
query.Add($"Ids={session.PlayerId}");
|
||||
}
|
||||
|
||||
if (query.Count == 0)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path + separator + string.Join("&", query);
|
||||
}
|
||||
|
||||
private static string HostFor(string path, string apexDomain)
|
||||
{
|
||||
var lower = path.ToLowerInvariant();
|
||||
|
||||
if (lower.StartsWith("/connect/")
|
||||
|| lower.StartsWith("/.well-known/")
|
||||
|| lower.StartsWith("/eac/")
|
||||
|| lower.StartsWith("/cachedlogin/")
|
||||
|| lower.StartsWith("/photon/")
|
||||
|| lower.StartsWith("/api/platformlogin/")
|
||||
|| lower.StartsWith("/api/version")
|
||||
|| lower.StartsWith("/api/auth/"))
|
||||
{
|
||||
return $"auth.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/account/"))
|
||||
{
|
||||
return $"accounts.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/roomserver/")
|
||||
|| lower.StartsWith("/rooms")
|
||||
|| lower.StartsWith("/playlists")
|
||||
|| lower.StartsWith("/featuredrooms")
|
||||
|| lower.StartsWith("/hot_rooms")
|
||||
|| lower.StartsWith("/search_rooms")
|
||||
|| lower.StartsWith("/api/rooms")
|
||||
|| lower.StartsWith("/api/roomkeys")
|
||||
|| lower.StartsWith("/api/gamesessions")
|
||||
|| lower.StartsWith("/api/curatedroomplaylists"))
|
||||
{
|
||||
return $"rooms.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/find")
|
||||
|| lower.StartsWith("/v1/join")
|
||||
|| lower.StartsWith("/v1/leave")
|
||||
|| lower.StartsWith("/v1/session")
|
||||
|| lower.StartsWith("/roominstance/")
|
||||
|| lower.StartsWith("/goto/")
|
||||
|| lower.StartsWith("/player/"))
|
||||
{
|
||||
return $"match.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/thread"))
|
||||
{
|
||||
return $"chat.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/club")
|
||||
|| lower.StartsWith("/announcements/")
|
||||
|| lower.StartsWith("/subscription/")
|
||||
|| lower.StartsWith("/members/")
|
||||
|| lower.StartsWith("/api/clubreporting/"))
|
||||
{
|
||||
return $"clubs.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/api/storefronts/")
|
||||
|| lower.StartsWith("/api/purchase/")
|
||||
|| lower.StartsWith("/api/subscription/"))
|
||||
{
|
||||
return $"commerce.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/hub/") || lower.StartsWith("/api/notification/"))
|
||||
{
|
||||
return $"notify.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/regions"))
|
||||
{
|
||||
return $"geo.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/services") || lower.StartsWith("/v1/ping"))
|
||||
{
|
||||
return $"ns.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/upload"))
|
||||
{
|
||||
return $"storage.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/img/") || lower.StartsWith("/room/") || lower.StartsWith("/data/"))
|
||||
{
|
||||
return $"cdn.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/api/admin/"))
|
||||
{
|
||||
return $"admin.{apexDomain}";
|
||||
}
|
||||
|
||||
return $"api.{apexDomain}";
|
||||
}
|
||||
|
||||
private static string? Excerpt(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var compact = body.ReplaceLineEndings(" ").Trim();
|
||||
return compact.Length <= 240 ? compact : compact[..240] + "...";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record EndpointProbeResult(
|
||||
string Method,
|
||||
string Host,
|
||||
string Path,
|
||||
string Expected,
|
||||
int? StatusCode,
|
||||
string? Error,
|
||||
string? ResponseExcerpt,
|
||||
bool Passed)
|
||||
{
|
||||
public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response";
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed record GameClientSession(
|
||||
long PlayerId,
|
||||
string AccessToken,
|
||||
string DeviceId,
|
||||
string PlatformId);
|
||||
|
||||
public static class GameClientSessionFactory
|
||||
{
|
||||
public static async Task<GameClientSession> CreateAsync(
|
||||
HttpClient client,
|
||||
string apexDomain)
|
||||
{
|
||||
var deviceId = $"endpoint-contract-device-{Guid.NewGuid():N}";
|
||||
var platformId = $"endpoint-contract-platform-{Guid.NewGuid():N}";
|
||||
var tokenRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
new Uri($"http://auth.{apexDomain}/connect/token"));
|
||||
tokenRequest.Headers.Accept.ParseAdd("application/json");
|
||||
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
|
||||
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
|
||||
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["username"] = "EndpointContract",
|
||||
["device_id"] = deviceId,
|
||||
["platform"] = "0",
|
||||
["platform_id"] = platformId,
|
||||
});
|
||||
|
||||
using var tokenResponse = await client.SendAsync(tokenRequest);
|
||||
if (!tokenResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await tokenResponse.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Test player token request failed with {(int)tokenResponse.StatusCode} {tokenResponse.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
using var tokenJson = await JsonDocument.ParseAsync(
|
||||
await tokenResponse.Content.ReadAsStreamAsync());
|
||||
var accessToken = tokenJson.RootElement.GetProperty("access_token").GetString()
|
||||
?? throw new InvalidOperationException("Token response did not include access_token.");
|
||||
|
||||
var userInfoRequest = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
new Uri($"http://auth.{apexDomain}/connect/userinfo"));
|
||||
userInfoRequest.Headers.Accept.ParseAdd("application/json");
|
||||
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
|
||||
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
|
||||
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using var userInfoResponse = await client.SendAsync(userInfoRequest);
|
||||
userInfoResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var userInfoJson = await JsonDocument.ParseAsync(
|
||||
await userInfoResponse.Content.ReadAsStreamAsync());
|
||||
var playerId = long.Parse(userInfoJson.RootElement.GetProperty("sub").GetString()!);
|
||||
|
||||
return new GameClientSession(playerId, accessToken, deviceId, platformId);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Compat;
|
||||
using DorkNet.Server.Controllers.Auth;
|
||||
using DorkNet.Server.Versions.Late2020;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class VersionCheckControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void December_2020_build_is_valid_with_minimal_service_config()
|
||||
{
|
||||
var controller = CreateController();
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.Check("20201210", "0"));
|
||||
|
||||
Assert.Equal(0, ReadVersionStatus(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_build_still_requires_update()
|
||||
{
|
||||
var controller = CreateController();
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.Check("unknown", "0"));
|
||||
|
||||
Assert.Equal(2, ReadVersionStatus(result.Value));
|
||||
}
|
||||
|
||||
private static VersionCheckController CreateController()
|
||||
{
|
||||
var config = new ConfigurationBuilder().Build();
|
||||
var registry = new VersionRegistry(
|
||||
[new Late2020VersionPlugin()],
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"december_2020_12_18",
|
||||
},
|
||||
"december_2020_12_18");
|
||||
|
||||
return new VersionCheckController(
|
||||
config,
|
||||
registry,
|
||||
NullLogger<VersionCheckController>.Instance);
|
||||
}
|
||||
|
||||
private static int ReadVersionStatus(object? value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return document.RootElement.GetProperty("VersionStatus").GetInt32();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using Open.IdentityServer;
|
||||
using Open.IdentityServer.Models;
|
||||
using Open.IdentityServer.Services;
|
||||
using Open.IdentityServer.Validation;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
public sealed class IdentityServerSigningKeyProvider
|
||||
{
|
||||
private readonly string _legacySecret;
|
||||
|
||||
public IdentityServerSigningKeyProvider(IConfiguration config)
|
||||
{
|
||||
_legacySecret =
|
||||
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? config["Jwt:Secret"]
|
||||
?? throw new InvalidOperationException(
|
||||
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
|
||||
|
||||
CertificatePath = ResolveCertificatePath(config);
|
||||
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
|
||||
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
|
||||
if (!string.IsNullOrWhiteSpace(certificateBase64))
|
||||
{
|
||||
LoadedFromBase64 = true;
|
||||
Certificate = LoadCertificateFromBase64(certificateBase64, password);
|
||||
}
|
||||
else
|
||||
{
|
||||
Certificate = LoadOrCreateCertificate(CertificatePath, password);
|
||||
}
|
||||
SigningKey = new X509SecurityKey(Certificate);
|
||||
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
|
||||
}
|
||||
|
||||
public string CertificatePath { get; }
|
||||
public bool LoadedFromBase64 { get; }
|
||||
public string CertificateSource => LoadedFromBase64
|
||||
? "IdentityServer:SigningCertificateBase64"
|
||||
: CertificatePath;
|
||||
public X509Certificate2 Certificate { get; }
|
||||
public SecurityKey SigningKey { get; }
|
||||
public SecurityKey LegacyJwtKey { get; }
|
||||
public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
|
||||
|
||||
private static string ResolveCertificatePath(IConfiguration config)
|
||||
{
|
||||
var path = config["IdentityServer:SigningCertificatePath"];
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
|
||||
return path;
|
||||
}
|
||||
|
||||
private static X509Certificate2 LoadCertificateFromBase64(string certificateBase64, string password)
|
||||
{
|
||||
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
|
||||
var compact = NormalizeCertificateBase64(certificateBase64);
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(compact);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"IdentityServer signing certificate base64 is invalid. " +
|
||||
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
|
||||
"without a file path or shell assignment text.",
|
||||
ex);
|
||||
}
|
||||
try
|
||||
{
|
||||
return new X509Certificate2(bytes, password, flags);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
|
||||
return certificate;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
|
||||
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
|
||||
"IdentityServer__SigningCertificatePassword matches the PFX password.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryLoadPemCertificate(
|
||||
byte[] bytes,
|
||||
string password,
|
||||
X509KeyStorageFlags flags,
|
||||
out X509Certificate2 certificate)
|
||||
{
|
||||
certificate = null!;
|
||||
|
||||
string pem;
|
||||
try
|
||||
{
|
||||
pem = Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
|
||||
if (!pemCertificate.HasPrivateKey)
|
||||
return false;
|
||||
|
||||
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
|
||||
certificate = new X509Certificate2(pfxBytes, password, flags);
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeCertificateBase64(string value)
|
||||
{
|
||||
var text = value.Trim().Trim('"', '\'');
|
||||
var equals = text.IndexOf('=');
|
||||
if (equals > 0)
|
||||
{
|
||||
var key = text[..equals].Trim();
|
||||
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
|
||||
text = text[(equals + 1)..].Trim().Trim('"', '\'');
|
||||
}
|
||||
|
||||
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var comma = text.IndexOf(',');
|
||||
if (comma >= 0)
|
||||
text = text[(comma + 1)..];
|
||||
}
|
||||
|
||||
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
|
||||
text = text["base64:".Length..];
|
||||
|
||||
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
return (compact.Length % 4) switch
|
||||
{
|
||||
2 => compact + "==",
|
||||
3 => compact + "=",
|
||||
_ => compact,
|
||||
};
|
||||
}
|
||||
|
||||
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
|
||||
{
|
||||
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
if (File.Exists(path))
|
||||
return new X509Certificate2(path, password, flags);
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
"CN=DorkNet IdentityServer Signing",
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
request.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
|
||||
|
||||
using var created = request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
var bytes = created.Export(X509ContentType.Pfx, password);
|
||||
File.WriteAllBytes(path, bytes);
|
||||
return new X509Certificate2(bytes, password, flags);
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecRoomIdentityServerRegistration
|
||||
{
|
||||
public static void AddRecRoomIdentityServer(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
DomainConfig domain,
|
||||
IdentityServerSigningKeyProvider signingKeyProvider)
|
||||
{
|
||||
services
|
||||
.AddIdentityServer(options =>
|
||||
{
|
||||
options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
|
||||
})
|
||||
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
|
||||
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
|
||||
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
|
||||
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
|
||||
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
|
||||
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
|
||||
.AddProfileService<RecRoomProfileService>();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RecRoomIdentityServerConfig
|
||||
{
|
||||
public const string ClientId = "recroom";
|
||||
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
|
||||
public const string GameClientScope = "gameClient";
|
||||
public const string CachedLoginGrantType = "cached_login";
|
||||
|
||||
public static readonly IdentityResource[] IdentityResources =
|
||||
{
|
||||
new IdentityResources.OpenId(),
|
||||
new IdentityResources.Profile(),
|
||||
};
|
||||
|
||||
public static readonly ApiScope[] ApiScopes =
|
||||
{
|
||||
new(GameClientScope, "Game client")
|
||||
{
|
||||
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
|
||||
},
|
||||
new("api", "DorkNet API")
|
||||
{
|
||||
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly Client[] Clients =
|
||||
{
|
||||
new()
|
||||
{
|
||||
ClientId = ClientId,
|
||||
ClientName = "Rec Room game client",
|
||||
RequireClientSecret = true,
|
||||
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
|
||||
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
|
||||
AllowedScopes = RecRoomScopes(),
|
||||
AllowOfflineAccess = true,
|
||||
AccessTokenLifetime = 60 * 60 * 12,
|
||||
AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
|
||||
SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
|
||||
RefreshTokenUsage = TokenUsage.ReUse,
|
||||
RefreshTokenExpiration = TokenExpiration.Absolute,
|
||||
UpdateAccessTokenClaimsOnRefresh = true,
|
||||
AlwaysIncludeUserClaimsInIdToken = true,
|
||||
},
|
||||
};
|
||||
|
||||
private static ICollection<string> RecRoomScopes() =>
|
||||
[
|
||||
IdentityServerConstants.StandardScopes.OpenId,
|
||||
IdentityServerConstants.StandardScopes.Profile,
|
||||
IdentityServerConstants.StandardScopes.OfflineAccess,
|
||||
GameClientScope,
|
||||
"api",
|
||||
];
|
||||
}
|
||||
|
||||
public sealed class IdentityServerGameTokenRequestMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!IsTokenRequest(context))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var values = form.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var changed = false;
|
||||
|
||||
if (IsDevicePasswordGrant(values))
|
||||
{
|
||||
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
|
||||
{
|
||||
values["username"] = First(
|
||||
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
|
||||
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
|
||||
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
|
||||
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
|
||||
"anonymous");
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
|
||||
{
|
||||
values["password"] = "__dorknet_device_login__";
|
||||
values["dorknet_device_login"] = "true";
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
var scopes = values.TryGetValue("scope", out var rawScope)
|
||||
? rawScope.ToString()
|
||||
: string.Empty;
|
||||
var mergedScopes = MergeScopes(scopes);
|
||||
if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
|
||||
{
|
||||
values["scope"] = mergedScopes;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(values)));
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private static bool IsTokenRequest(HttpContext context) =>
|
||||
HttpMethods.IsPost(context.Request.Method)
|
||||
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
|
||||
&& context.Request.HasFormContentType;
|
||||
|
||||
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
|
||||
{
|
||||
if (!values.TryGetValue("grant_type", out var grantType) ||
|
||||
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
return !values.TryGetValue("password", out var password) ||
|
||||
string.IsNullOrWhiteSpace(password.ToString());
|
||||
}
|
||||
|
||||
private static string First(params string?[] values) =>
|
||||
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
|
||||
|
||||
private static string MergeScopes(string existing)
|
||||
{
|
||||
var scopes = existing
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
|
||||
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
|
||||
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
|
||||
scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
|
||||
return string.Join(' ', scopes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RecRoomPasswordValidator(
|
||||
PlayerService playerService,
|
||||
LevelService level,
|
||||
ServerSettingsService settings,
|
||||
DorkNetDbContext db,
|
||||
ILogger<RecRoomPasswordValidator> logger) : IResourceOwnerPasswordValidator
|
||||
{
|
||||
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
|
||||
{
|
||||
var raw = context.Request.Raw;
|
||||
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
|
||||
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
|
||||
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
|
||||
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!deviceLogin && !string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
|
||||
{
|
||||
var byName = await playerService.GetByUsernameAsync(context.UserName);
|
||||
if (byName is null || byName.PasswordHash is null)
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"unknown_user_or_no_password");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"password_mismatch");
|
||||
return;
|
||||
}
|
||||
|
||||
await playerService.TagPlatformAsync(byName.Id, platform, platformId);
|
||||
await TryAwardLoginXpAsync(byName.Id);
|
||||
context.Result = await CreateSuccessAsync(byName, "password");
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
|
||||
? deviceId
|
||||
: $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
|
||||
|
||||
if (await settings.AreSignupsDisabledAsync())
|
||||
{
|
||||
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
|
||||
if (existing is null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
|
||||
effectiveDeviceId);
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"signups_disabled");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: effectiveDeviceId,
|
||||
platform: platform,
|
||||
platformId: platformId,
|
||||
displayName: context.UserName);
|
||||
await TryAwardLoginXpAsync(player.Id);
|
||||
context.Result = await CreateSuccessAsync(player, "device_password");
|
||||
}
|
||||
|
||||
private async Task<GrantValidationResult> CreateSuccessAsync(PlayerEntity player, string authMethod) =>
|
||||
new(
|
||||
subject: player.Id.ToString(),
|
||||
authenticationMethod: authMethod,
|
||||
claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
|
||||
|
||||
private async Task TryAwardLoginXpAsync(long playerId)
|
||||
{
|
||||
var lastSeen = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.LastSeenAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
|
||||
private static string? First(params string?[] values) =>
|
||||
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||
}
|
||||
|
||||
public sealed class CachedLoginGrantValidator(
|
||||
PlayerService playerService,
|
||||
LevelService level,
|
||||
OrphanAccountTracker orphans,
|
||||
DorkNetDbContext db,
|
||||
ILogger<CachedLoginGrantValidator> logger) : IExtensionGrantValidator
|
||||
{
|
||||
public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
|
||||
|
||||
public async Task ValidateAsync(ExtensionGrantValidationContext context)
|
||||
{
|
||||
var raw = context.Request.Raw;
|
||||
if (!long.TryParse(raw.Get("account_id"), out var pickedId))
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"missing account_id");
|
||||
return;
|
||||
}
|
||||
|
||||
var picked = await playerService.GetByIdAsync(pickedId);
|
||||
if (picked is null)
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"unknown account_id");
|
||||
return;
|
||||
}
|
||||
|
||||
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
|
||||
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
|
||||
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
|
||||
|
||||
var pendingId = orphans.PeekPending(deviceId, platformId);
|
||||
if (pendingId is long orphanId && orphanId != pickedId)
|
||||
{
|
||||
await playerService.DeleteOrphanAsync(orphanId);
|
||||
logger.LogInformation(
|
||||
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
|
||||
pickedId,
|
||||
orphanId);
|
||||
}
|
||||
orphans.Clear(deviceId, platformId);
|
||||
|
||||
await TryAwardLoginXpAsync(picked.Id);
|
||||
await playerService.TagPlatformAsync(picked.Id, platform, platformId);
|
||||
context.Result = new GrantValidationResult(
|
||||
subject: picked.Id.ToString(),
|
||||
authenticationMethod: GrantType,
|
||||
claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
|
||||
}
|
||||
|
||||
private async Task TryAwardLoginXpAsync(long playerId)
|
||||
{
|
||||
var lastSeen = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.LastSeenAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
|
||||
private static string? First(params string?[] values) =>
|
||||
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||
}
|
||||
|
||||
public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
|
||||
{
|
||||
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
|
||||
{
|
||||
if (!TryGetSubjectId(context.Subject, out var playerId))
|
||||
return;
|
||||
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
if (player is null)
|
||||
return;
|
||||
|
||||
context.IssuedClaims.AddRange(
|
||||
await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
|
||||
}
|
||||
|
||||
public async Task IsActiveAsync(IsActiveContext context)
|
||||
{
|
||||
context.IsActive =
|
||||
TryGetSubjectId(context.Subject, out var playerId)
|
||||
&& await playerService.GetByIdAsync(playerId) is not null;
|
||||
}
|
||||
|
||||
private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
|
||||
{
|
||||
var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return long.TryParse(value, out playerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecRoomIdentityClaims
|
||||
{
|
||||
public static readonly List<string> UserClaimTypes =
|
||||
[
|
||||
JwtRegisteredClaimNames.Sub,
|
||||
ClaimTypes.NameIdentifier,
|
||||
"name",
|
||||
"role",
|
||||
ClaimTypes.Role,
|
||||
"roles",
|
||||
"accountId",
|
||||
"account_id",
|
||||
"accountid",
|
||||
];
|
||||
|
||||
public static async Task<List<Claim>> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
|
||||
{
|
||||
var playerInfo = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
|
||||
.FirstOrDefaultAsync();
|
||||
var name = username ?? playerInfo?.Username ?? playerId.ToString();
|
||||
var isDev = playerInfo?.IsDev ?? false;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, playerId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
|
||||
new("name", name),
|
||||
new("accountId", playerId.ToString()),
|
||||
new("account_id", playerId.ToString()),
|
||||
new("accountid", playerId.ToString()),
|
||||
new(ClaimTypes.Role, "gameClient"),
|
||||
new("role", "gameClient"),
|
||||
new("roles", "gameClient"),
|
||||
};
|
||||
|
||||
if (isDev)
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, "developer"));
|
||||
claims.Add(new Claim("role", "developer"));
|
||||
claims.Add(new Claim("roles", "developer"));
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
@@ -1,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;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Compat2018;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="PlayerEntity"/> to the June-2018 wire player object
|
||||
/// (<see cref="Player2018"/>). Lives in the server (not DorkNet.Models) because
|
||||
/// the DTO project can't reference the data-layer entity. Shared by the
|
||||
/// platformlogin/v1, players/v1, and presence controllers so the 2018 player
|
||||
/// shape is produced in exactly one place.
|
||||
/// Keys verified against RecNet.EEBPLECPEGD.Deserialize (RecNet.cs:64632).
|
||||
/// </summary>
|
||||
public static class Player2018Mapper
|
||||
{
|
||||
public static Player2018 From(PlayerEntity p) => new()
|
||||
{
|
||||
Id = p.Id,
|
||||
Username = p.Username,
|
||||
DisplayName = string.IsNullOrEmpty(p.DisplayName) ? p.Username : p.DisplayName,
|
||||
Bio = p.Bio ?? string.Empty,
|
||||
XP = p.XP,
|
||||
Level = p.Level,
|
||||
Developer = p.IsDeveloper,
|
||||
CanReceiveInvites = p.CanReceiveInvites,
|
||||
ProfileImageName = p.ProfileImageName ?? string.Empty,
|
||||
JuniorProfile = p.IsJunior,
|
||||
HasBirthday = p.Birthday.HasValue,
|
||||
PlayerReputation = new PlayerReputation2018 { Noteriety = p.Reputation },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using DorkNet.Models.Rooms;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Compat2018;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="RoomEntity"/> to the flat June-2018 wire room
|
||||
/// (<see cref="Room2018"/>). Server-side because DorkNet.Models can't reference
|
||||
/// the data entity. Some 2018 fields have no column (Instanced, MaxPlayers,
|
||||
/// ReportCount, ActivityLevelId) and get sensible defaults; IsSandbox is derived
|
||||
/// from IsAGRoom (official AG rooms are not sandbox/maker rooms).
|
||||
/// </summary>
|
||||
public static class Room2018Mapper
|
||||
{
|
||||
public static Room2018 From(RoomEntity r) => new()
|
||||
{
|
||||
RoomId = r.Id,
|
||||
Name = r.Name,
|
||||
Description = r.Description,
|
||||
CreatorPlayerId = r.CreatorPlayerId,
|
||||
ImageName = r.ImageName,
|
||||
DataBlobName = r.CurrentDataBlobName ?? string.Empty,
|
||||
ActivityLevelId = string.Empty,
|
||||
IsSandbox = !r.IsAGRoom,
|
||||
Instanced = true,
|
||||
MaxPlayers = 20,
|
||||
Accessibility = r.Accessibility,
|
||||
AccessibilityLocked = false,
|
||||
VisitorCount = r.VisitorCount,
|
||||
VisitCount = r.VisitCount,
|
||||
CheerCount = r.CheerCount,
|
||||
ReportCount = 0,
|
||||
State = r.State,
|
||||
StateModifiedAt = r.UpdatedAt,
|
||||
CreatedAt = r.CreatedAt,
|
||||
ModifiedAt = r.UpdatedAt,
|
||||
LastVisitedAt = r.UpdatedAt,
|
||||
DataModifiedAt = r.UpdatedAt,
|
||||
CoOwners = [],
|
||||
Hosts = [],
|
||||
PersonalDetails = null,
|
||||
};
|
||||
}
|
||||
@@ -128,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 });
|
||||
}
|
||||
|
||||
@@ -155,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 });
|
||||
}
|
||||
|
||||
@@ -201,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,
|
||||
@@ -225,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
|
||||
|
||||
@@ -198,32 +198,6 @@ public class AvatarGiftsController(
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gift.EquipmentPrefabName) ||
|
||||
!string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid))
|
||||
{
|
||||
var itemSlug = !string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid)
|
||||
? StoreService.EquipmentSkinSlug(gift.EquipmentPrefabName, gift.EquipmentModificationGuid)
|
||||
: gift.EquipmentPrefabName;
|
||||
if (!string.IsNullOrWhiteSpace(itemSlug))
|
||||
{
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == itemSlug);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerInventory.Add(new PlayerInventoryEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
ItemSlug = itemSlug,
|
||||
Quantity = 1,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Quantity = Math.Max(row.Quantity, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply currency / XP / level rewards bundled with the gift.
|
||||
// The previous implementation only handled the avatar item path,
|
||||
// so token gifts from the admin SPA marked themselves consumed
|
||||
@@ -278,17 +252,7 @@ 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.
|
||||
public static object ToWire(GiftPackageEntity g) => new
|
||||
private static object ToWire(GiftPackageEntity g) => new
|
||||
{
|
||||
g.Id,
|
||||
g.FromPlayerId,
|
||||
@@ -306,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,
|
||||
|
||||
@@ -26,6 +26,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private const int MaxSlots = 20;
|
||||
|
||||
[HttpGet("api/avatar/v1/saved")]
|
||||
[HttpGet("api/avatar/v2/saved")]
|
||||
[HttpGet("api/avatar/v3/saved")]
|
||||
[HttpGet("api/avatar/v4/saved")]
|
||||
@@ -39,6 +40,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
|
||||
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
||||
/// Body is the SavedOutfit shape directly.</summary>
|
||||
[HttpPost("api/avatar/v1/saved/set")]
|
||||
[HttpPost("api/avatar/v3/saved/set")]
|
||||
[HttpPost("api/avatar/v2/saved/set")]
|
||||
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
|
||||
|
||||
@@ -85,16 +85,8 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
|
||||
return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
// Defaults raised (30 → 100, 4 → 20) — the old 4-per-slot silently
|
||||
// dropped any newly-purchased item once a slot held 4+ entries (the
|
||||
// "I bought it but it's gone after relog" symptom). The clamp
|
||||
// ranges stay at [1,120] / [1,20] — those reflect empirical watch
|
||||
// testing on render-budget headroom (per the prior maintainer note
|
||||
// about BrowsableAvatarItem / DynamicAvatarItemImposter thrash
|
||||
// above this count); raising the clamp ceiling without retesting
|
||||
// could crash the watch's wardrobe drawer.
|
||||
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 100), 1, 120);
|
||||
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 20), 1, 20);
|
||||
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>());
|
||||
@@ -104,7 +96,7 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
.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)
|
||||
@@ -117,54 +109,28 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
|
||||
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),
|
||||
PlatformMask = item.PlatformMask,
|
||||
FriendlyName = item.FriendlyName,
|
||||
Tooltip = item.Tooltip,
|
||||
Rarity = item.Rarity,
|
||||
});
|
||||
result.Add(item);
|
||||
if (result.Count >= maxItems) break;
|
||||
}
|
||||
|
||||
@@ -200,49 +166,6 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
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>());
|
||||
|
||||
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
|
||||
@@ -253,103 +176,47 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
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(); }
|
||||
|
||||
return result
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
const string wardrobePrefix = "wardrobe-";
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var result = new List<OwnedEntry>();
|
||||
foreach (var entry in raw)
|
||||
{
|
||||
var value = entry;
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
|
||||
value = value[wardrobePrefix.Length..];
|
||||
var comma = value.IndexOf(',');
|
||||
var baseGuid = comma >= 0 ? value[..comma] : value;
|
||||
if (string.IsNullOrWhiteSpace(baseGuid)) continue;
|
||||
if (!seen.Add(value)) continue;
|
||||
result.Add(new OwnedEntry(baseGuid, value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Bare-GUID view of the owned set, for callers that only need
|
||||
/// ownership membership (e.g. the locked-items diff), not the swatch.</summary>
|
||||
private static List<string> ParseInventory(string? json) =>
|
||||
ParseInventoryEntries(json)
|
||||
.Select(e => e.BaseGuid)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Ensure a stored desc carries the ≥4 comma-separated parts
|
||||
/// (guid + 3 customizations) that the watch's
|
||||
/// <c>AvatarItem.FromRecNetString</c> requires — anything shorter throws
|
||||
/// inside the wardrobe parser. Stored colored descs are already
|
||||
/// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy
|
||||
/// value.</summary>
|
||||
private static string ToWatchDesc(string storedDesc)
|
||||
{
|
||||
var commas = storedDesc.Count(c => c == ',');
|
||||
return commas >= 3 ? storedDesc : storedDesc + new string(',', 3 - commas);
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
|
||||
@@ -415,13 +282,10 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
{
|
||||
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
|
||||
@@ -485,22 +349,11 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
{
|
||||
[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; }
|
||||
|
||||
@@ -67,19 +67,9 @@ public class CommunityBoardController(
|
||||
.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 = r.ImageName ?? string.Empty }
|
||||
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
|
||||
.ToArray(),
|
||||
// Keep the old key around for any 2020.03 clients hitting prod.
|
||||
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
|
||||
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
|
||||
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Compatibility;
|
||||
|
||||
[ApiController]
|
||||
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/banappeal/generateCode")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GenerateBanAppealCode()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var code = $"BA-{RandomNumberGenerator.GetInt32(100000, 999999)}";
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"banappeal:{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
Value = code,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { value = code });
|
||||
}
|
||||
|
||||
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> UpdateAndGetCampusCardSubscription()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadCampusCardRequestAsync();
|
||||
var targetId = req.PlatformAccountSubscribedPlayerId > 0
|
||||
? req.PlatformAccountSubscribedPlayerId
|
||||
: pid;
|
||||
|
||||
if (targetId != pid && req.Subscription)
|
||||
{
|
||||
var exists = await db.Subscriptions.AnyAsync(s =>
|
||||
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
|
||||
if (!exists)
|
||||
{
|
||||
db.Subscriptions.Add(new SubscriptionEntity
|
||||
{
|
||||
SubscriberPlayerId = pid,
|
||||
TargetPlayerId = targetId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
else if (targetId != pid && !req.Subscription)
|
||||
{
|
||||
await db.Subscriptions
|
||||
.Where(s => s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
// 2020.12 deserializer for this response expects `subscription` to be a
|
||||
// nested OBJECT (Subscription DTO with subscriptionId/recNetPlayerId/...
|
||||
// fields), NOT a boolean. Sending a bool throws InvalidCastException
|
||||
// on the client ("Unable to cast object of type 'Boolean' to type
|
||||
// 'Dictionary`2'"). Send null when there's no active subscription —
|
||||
// the client's generic-cast helper returns default(T) which is fine.
|
||||
return Ok(new
|
||||
{
|
||||
subscription = (object?)null,
|
||||
platformAccountSubscribedPlayerId = targetId,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("api/clubreporting/v1/report")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ClubReport()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadClubReportAsync();
|
||||
var club = req.ClubId > 0
|
||||
? await db.Clubs.FirstOrDefaultAsync(c => c.Id == req.ClubId)
|
||||
: null;
|
||||
if (club is null) return Ok(new { Success = false, Message = "club_not_found" });
|
||||
|
||||
var message = $"Club report: {req.Message ?? string.Empty}".Trim();
|
||||
if (message.Length > 1000) message = message[..1000];
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = pid,
|
||||
TargetPlayerId = club.CreatorPlayerId,
|
||||
Category = req.ReportCategory,
|
||||
Message = message,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
private async Task<CampusCardRequest> ReadCampusCardRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new CampusCardRequest
|
||||
{
|
||||
Subscription = bool.TryParse(form["subscription"].FirstOrDefault(), out var s) && s,
|
||||
PlatformAccountSubscribedPlayerId = long.TryParse(
|
||||
form["platformAccountSubscribedPlayerId"].FirstOrDefault(), out var id) ? id : 0,
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<CampusCardRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new CampusCardRequest();
|
||||
}
|
||||
catch (JsonException) { return new CampusCardRequest(); }
|
||||
}
|
||||
|
||||
private async Task<ClubReportRequest> ReadClubReportAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new ClubReportRequest
|
||||
{
|
||||
ClubId = long.TryParse(form["clubId"].FirstOrDefault()
|
||||
?? form["ClubId"].FirstOrDefault(), out var clubId) ? clubId : 0,
|
||||
ReportCategory = int.TryParse(form["reportCategory"].FirstOrDefault()
|
||||
?? form["ReportCategory"].FirstOrDefault(), out var cat) ? cat : 5,
|
||||
Message = form["message"].FirstOrDefault() ?? form["Message"].FirstOrDefault(),
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<ClubReportRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new ClubReportRequest();
|
||||
}
|
||||
catch (JsonException) { return new ClubReportRequest(); }
|
||||
}
|
||||
|
||||
private sealed class CampusCardRequest
|
||||
{
|
||||
public bool Subscription { get; set; }
|
||||
public long PlatformAccountSubscribedPlayerId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ClubReportRequest
|
||||
{
|
||||
public long ClubId { get; set; }
|
||||
public int ReportCategory { get; set; } = 5;
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -21,13 +21,6 @@ public class ConfigController(ConfigService configService) : ControllerBase
|
||||
[HttpGet("amplitude")]
|
||||
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
|
||||
|
||||
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
|
||||
/// 2020.12 client deserializes this endpoint as a raw Boolean.
|
||||
/// This server does not run the timed free-gift campaign, so the
|
||||
/// button is hidden.</summary>
|
||||
[HttpGet("freegiftbutton")]
|
||||
public IActionResult FreeGiftButton() => Content("false", "application/json");
|
||||
|
||||
/// <summary>
|
||||
/// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four
|
||||
/// "What would you like to do first?" buttons shown in the OOBE
|
||||
|
||||
@@ -15,5 +15,5 @@ public class ServiceUrlsController(DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet("services")]
|
||||
public ActionResult<Dictionary<string, string>> Get()
|
||||
=> Ok(ConfigService.BuildServiceUrlMap(domain.Apex));
|
||||
=> Ok(ConfigService.BuildServiceUrlMap(domain));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameConfigurations;
|
||||
|
||||
[ApiController]
|
||||
public class GameConfigurationsController(ServerSettingsService serverSettings) : ControllerBase
|
||||
public class GameConfigurationsController : ControllerBase
|
||||
{
|
||||
// Actual path used by the 2020 build
|
||||
[HttpGet("api/gameconfigs/v1/all")]
|
||||
@@ -12,6 +11,5 @@ public class GameConfigurationsController(ServerSettingsService serverSettings)
|
||||
// Legacy path we originally assumed
|
||||
[HttpGet("api/gameconfigurations/v1/all")]
|
||||
[HttpGet("api/gameconfigurations/v1")]
|
||||
public async Task<ActionResult<IReadOnlyList<GameConfigurationSetting>>> GetAll()
|
||||
=> Ok(await serverSettings.GetGameConfigurationsAsync());
|
||||
public ActionResult<List<object>> GetAll() => Ok(new List<object>());
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameRewards;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class GameRewardsController(DorkNetDbContext db, LevelService level) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("api/gamerewards/v1/pending")]
|
||||
public async Task<IActionResult> Pending()
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await db.GameRewardSelections
|
||||
.Where(r => r.PlayerId == pid && r.SelectedAt == null)
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.Take(20)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost("api/gamerewards/v1/request")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> RequestReward()
|
||||
{
|
||||
var req = await ReadRewardRequestAsync();
|
||||
var row = new GameRewardSelectionEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
RewardType = req.RewardType,
|
||||
GiftContext = req.GiftContext,
|
||||
Message = string.IsNullOrWhiteSpace(req.Message) ? "Choose a reward" : req.Message[..Math.Min(req.Message.Length, 256)],
|
||||
};
|
||||
db.GameRewardSelections.Add(row);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/gamerewards/v1/select")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> SelectReward()
|
||||
{
|
||||
var req = await ReadRewardSelectAsync();
|
||||
var pid = Me;
|
||||
var row = await db.GameRewardSelections
|
||||
.FirstOrDefaultAsync(r => r.Id == req.RewardSelectionId && r.PlayerId == pid && r.SelectedAt == null);
|
||||
if (row is null) return Ok(new { Success = false, Error = "reward_not_found" });
|
||||
|
||||
row.SelectedAt = DateTime.UtcNow;
|
||||
row.SelectedGiftDropId = req.GiftDropId;
|
||||
await level.GrantCurrencyAsync(pid, 2, 25, $"gameReward:{row.Id}");
|
||||
await level.AwardXpAsync(pid, 25, $"gameReward:{row.Id}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
private static object ToWire(GameRewardSelectionEntity row) => new
|
||||
{
|
||||
RewardSelectionId = row.Id,
|
||||
row.Message,
|
||||
row.GiftContext,
|
||||
row.RewardType,
|
||||
GiftDrop1 = GiftDrop(row.Id * 10 + 1, "Tokens", currency: 25),
|
||||
GiftDrop2 = GiftDrop(row.Id * 10 + 2, "XP", currency: 0),
|
||||
GiftDrop3 = GiftDrop(row.Id * 10 + 3, "Bonus Tokens", currency: 25),
|
||||
row.CreatedAt,
|
||||
};
|
||||
|
||||
private static object GiftDrop(long id, string name, int currency) => new
|
||||
{
|
||||
GiftDropId = id,
|
||||
FriendlyName = name,
|
||||
Tooltip = string.Empty,
|
||||
ConsumableItemDesc = string.Empty,
|
||||
AvatarItemDesc = string.Empty,
|
||||
AvatarItemType = 0,
|
||||
EquipmentPrefabName = string.Empty,
|
||||
EquipmentModificationGuid = string.Empty,
|
||||
IsQuery = false,
|
||||
Unique = false,
|
||||
SubscribersOnly = false,
|
||||
Rarity = 0,
|
||||
CurrencyType = 2,
|
||||
Currency = currency,
|
||||
Context = 0,
|
||||
ItemSetId = 0,
|
||||
ItemSetFriendlyName = string.Empty,
|
||||
};
|
||||
|
||||
private async Task<RewardRequest> ReadRewardRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new RewardRequest
|
||||
{
|
||||
RewardType = int.TryParse(form["rewardType"].FirstOrDefault(), out var rt) ? rt : 0,
|
||||
GiftContext = int.TryParse(form["giftContext"].FirstOrDefault(), out var gc) ? gc : 0,
|
||||
Message = form["Message"].FirstOrDefault() ?? form["message"].FirstOrDefault(),
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<RewardRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RewardRequest();
|
||||
}
|
||||
catch (JsonException) { return new RewardRequest(); }
|
||||
}
|
||||
|
||||
private async Task<RewardSelect> ReadRewardSelectAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new RewardSelect
|
||||
{
|
||||
RewardSelectionId = long.TryParse(form["rewardSelectionId"].FirstOrDefault(), out var id) ? id : 0,
|
||||
GiftDropId = int.TryParse(form["giftDropId"].FirstOrDefault(), out var giftDropId) ? giftDropId : 0,
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<RewardSelect>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RewardSelect();
|
||||
}
|
||||
catch (JsonException) { return new RewardSelect(); }
|
||||
}
|
||||
|
||||
private sealed class RewardRequest
|
||||
{
|
||||
public int RewardType { get; set; }
|
||||
public int GiftContext { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RewardSelect
|
||||
{
|
||||
public long RewardSelectionId { get; set; }
|
||||
public int GiftDropId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,10 @@ namespace DorkNet.Server.Controllers.API.Images;
|
||||
/// Returning [] (the previous catch-all) made the watch silently
|
||||
/// drop the photo.
|
||||
///
|
||||
/// Saved photos are stored as <see cref="PhotoEntity"/> rows. The 2020
|
||||
/// client sends camera share state inside an imgMeta JSON form field
|
||||
/// (accessibility=1 for public ShareCamera uploads), while newer callers
|
||||
/// can still send top-level IsPublic.
|
||||
/// Saved photos are stored as <see cref="PhotoEntity"/> rows with
|
||||
/// IsPublic=false by default. A separate Share button (POST
|
||||
/// api/images/v4/share/{id}) flips IsPublic=true and surfaces them on
|
||||
/// feed.rec.net.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ImagesController(
|
||||
@@ -68,6 +68,13 @@ public class ImagesController(
|
||||
[HttpPost("api/images/v3/uploadsaved")]
|
||||
[HttpPost("api/images/v4/uploadsaved")]
|
||||
[HttpPost("api/images/v5/uploadsaved")]
|
||||
// 2018 flat-screen (non-VR) camera save posts a single 'image' part to
|
||||
// uploadsavedsingle (RecNet.cs:35080); ReadImageBytesAsync already falls
|
||||
// back to the first uploaded file, so the same handler serves it.
|
||||
[HttpPost("api/images/v2/uploadsavedsingle")]
|
||||
[HttpPost("api/images/v3/uploadsavedsingle")]
|
||||
[HttpPost("api/images/v4/uploadsavedsingle")]
|
||||
[HttpPost("api/images/v5/uploadsavedsingle")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UploadSaved()
|
||||
{
|
||||
@@ -82,17 +89,12 @@ public class ImagesController(
|
||||
// Pull optional metadata from the form (the 2020 watch sometimes
|
||||
// includes a caption / room context, sometimes posts bytes only).
|
||||
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
|
||||
var imageMeta = ParseImageMeta(form);
|
||||
var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty;
|
||||
var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(taggedIds) && imageMeta?.PlayerIds.Count > 0)
|
||||
taggedIds = string.Join(",", imageMeta.PlayerIds);
|
||||
long roomId = 0;
|
||||
if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid;
|
||||
if (roomId == 0 && imageMeta?.RoomId is > 0) roomId = imageMeta.RoomId.Value;
|
||||
if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0;
|
||||
var explicitPublic = form is null ? null : TryReadBool(form["IsPublic"].ToString());
|
||||
var isPublic = explicitPublic ?? (imageMeta?.Accessibility == 1 || imageMeta?.SavedImageType == 1);
|
||||
var isPublic = string.Equals(form?["IsPublic"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
var isProfileThumbnail = IsProfileThumbnail(form);
|
||||
LogUploadForm("uploadsaved", pid, form, isProfileThumbnail);
|
||||
|
||||
@@ -285,23 +287,16 @@ public class ImagesController(
|
||||
/// <summary>GET <c>api/images/v1/slideshow</c> — the watch's lobby
|
||||
/// slideshow info. Response shape per <c>SlideshowInfoDTO.Deserialize</c>:
|
||||
/// <c>{ValidTill (DateTime, required), Images (list, required)}</c>.
|
||||
/// A short <c>ValidTill</c> lets the Rec Center board pick up newly
|
||||
/// shared camera photos without needing a server restart.</summary>
|
||||
/// Far-future <c>ValidTill</c> stops the watch from re-polling.</summary>
|
||||
[HttpGet("api/images/v1/slideshow")]
|
||||
public async Task<IActionResult> SlideshowInfo()
|
||||
public IActionResult SlideshowInfo() => Ok(new
|
||||
{
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.IsPublic && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ValidTill = DateTime.UtcNow.AddMinutes(5),
|
||||
Images = rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)),
|
||||
});
|
||||
}
|
||||
// WireDates.FarFuture (2099), NOT year 9999 — a 9999 ValidTill
|
||||
// overflows the watch's local-timezone date parse in some DST
|
||||
// timezones → "malformed RecNet response". See WireDates.
|
||||
ValidTill = WireDates.FarFuture,
|
||||
Images = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
/// <summary>Binary image fallback for clients that request image bytes
|
||||
/// through api/images/{name} instead of img.rec.net/{name}. Specific JSON
|
||||
@@ -417,36 +412,6 @@ public class ImagesController(
|
||||
public long PhotoId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BulkCheeredRequest
|
||||
{
|
||||
public List<long>? SavedImageIds { get; set; }
|
||||
public List<long>? ImageIds { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/images/v4/cheered/bulk")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new())
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var cheered = await db.Cheers
|
||||
.Where(c => c.FromPlayerId == pid && ids.Contains(c.TargetPhotoId))
|
||||
.Select(c => c.TargetPhotoId)
|
||||
.ToListAsync();
|
||||
var set = cheered.ToHashSet();
|
||||
return Ok(ids.Select(id => new
|
||||
{
|
||||
SavedImageId = id,
|
||||
IsCheered = set.Contains(id),
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
|
||||
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
|
||||
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
|
||||
@@ -808,104 +773,6 @@ public class ImagesController(
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class ParsedImageMeta
|
||||
{
|
||||
public int? SavedImageType { get; init; }
|
||||
public int? Accessibility { get; init; }
|
||||
public long? RoomId { get; init; }
|
||||
public IReadOnlyList<long> PlayerIds { get; init; } = Array.Empty<long>();
|
||||
}
|
||||
|
||||
private static ParsedImageMeta? ParseImageMeta(IFormCollection? form)
|
||||
{
|
||||
if (form is null) return null;
|
||||
|
||||
var meta = form["imgMeta"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
meta = form["imageMeta"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(meta);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
return new ParsedImageMeta
|
||||
{
|
||||
SavedImageType = TryGetInt(doc.RootElement, "savedImageType", "SavedImageType", "imageType", "ImageType"),
|
||||
Accessibility = TryGetInt(doc.RootElement, "accessibility", "Accessibility"),
|
||||
RoomId = TryGetLong(doc.RootElement, "roomId", "RoomId"),
|
||||
PlayerIds = TryGetLongArray(doc.RootElement, "playerIds", "PlayerIds"),
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool? TryReadBool(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
if (bool.TryParse(value, out var parsed)) return parsed;
|
||||
if (value == "1") return true;
|
||||
if (value == "0") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? TryGetInt(JsonElement root, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var value))
|
||||
continue;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n))
|
||||
return n;
|
||||
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out n))
|
||||
return n;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? TryGetLong(JsonElement root, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var value))
|
||||
continue;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var n))
|
||||
return n;
|
||||
if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out n))
|
||||
return n;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<long> TryGetLongArray(JsonElement root, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
|
||||
continue;
|
||||
|
||||
return value.EnumerateArray()
|
||||
.Select(x => x.ValueKind == JsonValueKind.Number && x.TryGetInt64(out var n)
|
||||
? n
|
||||
: x.ValueKind == JsonValueKind.String && long.TryParse(x.GetString(), out n)
|
||||
? n
|
||||
: 0L)
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return Array.Empty<long>();
|
||||
}
|
||||
|
||||
/// <summary>Build the response shape every images/v* endpoint
|
||||
/// returns. PascalCase keys + redundant aliases so whichever JSON
|
||||
/// parser the 2020 watch uses, it finds the field it wants. The
|
||||
@@ -917,49 +784,24 @@ public class ImagesController(
|
||||
private object BuildImageInfo(PhotoEntity p, long accountId)
|
||||
{
|
||||
var cdnHost = domain.Sub("cdn");
|
||||
// Accessibility maps from IsPublic so the 2020.12 watch's IBANLCLBGLM
|
||||
// deserialiser reads a non-default value (0=Private, 1=Public).
|
||||
var accessibility = p.IsPublic ? 1 : 0;
|
||||
return new
|
||||
{
|
||||
// 2020.12 watch's IBANLCLBGLM strict keys (per
|
||||
// docs/recroom-2020-client-response-contracts.md:1048).
|
||||
// Missing any of these throws KeyNotFoundException in LitJson
|
||||
// and the gallery empties out.
|
||||
Id = p.Id,
|
||||
// Primary keys the 2020 watch's ImageInfo deserialiser reads.
|
||||
ImageName = p.BlobName,
|
||||
AccountId = (int)accountId,
|
||||
// Server-side mirror of AccountId as PlayerId — some
|
||||
// call-sites read PlayerId (lowercase id) instead of
|
||||
// AccountId. Including both is harmless on the watch.
|
||||
PlayerId = (int)accountId,
|
||||
RoomId = p.RoomId,
|
||||
// PlayerEventId is always 0 here (photo-event linkage isn't
|
||||
// tracked yet); the field still has to be present so the
|
||||
// strict deserialiser doesn't bail.
|
||||
PlayerEventId = 0L,
|
||||
Accessibility = accessibility,
|
||||
// AccessibilityLocked = whether the watch can flip the
|
||||
// privacy toggle. We don't gate on this server-side so the
|
||||
// value is always false (UI is free to toggle).
|
||||
AccessibilityLocked = false,
|
||||
// ImageType: 0=ScreenShot, 1=ProfileImage, 2=ImageOfMe, …
|
||||
// we don't differentiate yet — surface 0 unconditionally.
|
||||
Type = 0,
|
||||
ImageType = 0,
|
||||
CreatedAt = p.CreatedAt,
|
||||
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
|
||||
CheerCount = p.CheerCount,
|
||||
// We don't track per-photo comments yet — surface 0 for
|
||||
// the strict-key requirement.
|
||||
CommentCount = 0,
|
||||
// Bonus fields — useful for other clients (and our own feed UI).
|
||||
Id = p.Id,
|
||||
Filename = p.BlobName,
|
||||
Url = $"https://{cdnHost}/{p.BlobName}",
|
||||
Caption = p.Caption,
|
||||
Description = p.Caption,
|
||||
RoomId = p.RoomId,
|
||||
IsPublic = p.IsPublic,
|
||||
CheerCount = p.CheerCount,
|
||||
ViewCount = p.ViewCount,
|
||||
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,15 +19,13 @@ namespace DorkNet.Server.Controllers.API.Inventions;
|
||||
/// are POST. <c>delete</c>, <c>publish</c>, <c>unpublish</c>,
|
||||
/// <c>download</c> are also GET.
|
||||
///
|
||||
/// Wire DTO matches the watch's <c>OBBBPCBIMME.PPGFHEDFBEA</c>
|
||||
/// deserializer (Cpp2IL ISIL <c>OBBBPCBIMME.txt:588-704</c>): keys
|
||||
/// Wire DTO matches <c>RecNet.Invention</c>
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/Invention.cs</c>) byte-for-byte: keys
|
||||
/// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description,
|
||||
/// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
|
||||
/// ModifiedAt, CreatedAt, FirstPublishedAt, CreationRoomId,
|
||||
/// ImageName, CurrentVersionNumber, IsPublished, ModifiedAt,
|
||||
/// CreatedAt, FirstPublishedAt, CreationRoomId,
|
||||
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
|
||||
/// CreatorPermission, GeneralPermission, IsAGInvention, Price,
|
||||
/// HideFromPlayer</c>. Casing is significant — note IsAGInvention
|
||||
/// has a capital G.
|
||||
/// CreatorPermission, GeneralPermission, IsAgInvention</c>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class InventionsController(
|
||||
@@ -73,7 +71,6 @@ public class InventionsController(
|
||||
/// <summary><c>GetLocalPlayerInventions</c> — same as Saved but
|
||||
/// at v1 path. Watch hits both depending on tab.</summary>
|
||||
[HttpGet("api/inventions/v1/mine")]
|
||||
[HttpGet("api/inventions/v2/mine")]
|
||||
[Authorize]
|
||||
public Task<ActionResult> Mine() => Saved(100);
|
||||
|
||||
@@ -170,11 +167,11 @@ public class InventionsController(
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/creatorIds</c> — the distinct
|
||||
/// creator account ids of every invention placed in a room
|
||||
/// (<c>GetCreatorIdsOfInventionsInRoom</c>). The watch uses this to
|
||||
/// resolve which creators it must request invention permissions for
|
||||
/// before spawning a room's invention-based geometry. Returns
|
||||
/// <c>List<int></c> — empty when the room has no tracked
|
||||
/// inventions.</summary>
|
||||
/// (<c>GetCreatorIdsOfInventionsInRoom</c>, called by the 2020.03
|
||||
/// watch on room load). It uses these to resolve which creators it
|
||||
/// must request invention permissions for before spawning a room's
|
||||
/// invention-based geometry. Returns <c>List<int></c> — empty
|
||||
/// when the room has no tracked inventions.</summary>
|
||||
[HttpGet("api/inventions/v1/creatorIds")]
|
||||
public async Task<IActionResult> CreatorIdsInRoom([FromQuery] long roomId)
|
||||
{
|
||||
@@ -222,59 +219,8 @@ public class InventionsController(
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/fulllineageowner</c> —
|
||||
/// raw Boolean. The client uses this to decide whether a set of
|
||||
/// referenced inventions can be saved together under one owner.
|
||||
/// DorkNet tracks current creator ownership, so the concrete rule
|
||||
/// here is: every requested invention id must exist and share the
|
||||
/// same CreatorPlayerId.</summary>
|
||||
[HttpGet("api/inventions/v1/fulllineageowner")]
|
||||
public async Task<IActionResult> FullLineageOwner()
|
||||
{
|
||||
var ids = Request.Query
|
||||
.SelectMany(q => q.Value)
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0L)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
if (ids.Count == 0) return Content("true", "application/json");
|
||||
|
||||
var owners = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
|
||||
.Select(i => new { i.Id, i.CreatorPlayerId })
|
||||
.ToListAsync();
|
||||
var ok = owners.Count == ids.Count
|
||||
&& owners.Select(o => o.CreatorPlayerId).Distinct().Count() <= 1;
|
||||
return Content(ok ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
// ── Single fetch ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>/api/inventions/v1/room?id={roomId}</c> — every
|
||||
/// invention used by the room. The 2020.12 watch fires this on
|
||||
/// EVERY room load right after fetching the room data blob; it uses
|
||||
/// the response to pre-resolve invention references the blob will
|
||||
/// spawn. Without this, imported rooms with invention-based
|
||||
/// geometry (floors, walls, custom shapes) render with everything
|
||||
/// missing — the watch silently skips invention spawns when it
|
||||
/// has no list, and you fall through the void.
|
||||
///
|
||||
/// We match on <see cref="InventionEntity.CreationRoomId"/> which
|
||||
/// the zip importer populates from each Invention.json's
|
||||
/// <c>CreationRoomId</c> field. Returns an array of
|
||||
/// <see cref="ToWire"/>-shaped Invention objects (same wire shape
|
||||
/// the single-fetch endpoint returns) so the watch's existing
|
||||
/// deserializer works.</summary>
|
||||
[HttpGet("api/inventions/v1/room")]
|
||||
public async Task<ActionResult> ByRoom([FromQuery] long id)
|
||||
{
|
||||
var inventions = await ResolveRoomInventionsAsync(id);
|
||||
return Ok(inventions.Select(ToWire).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1")]
|
||||
public async Task<ActionResult> SingleByQuery([FromQuery] long inventionId)
|
||||
{
|
||||
@@ -338,87 +284,53 @@ public class InventionsController(
|
||||
|
||||
// ── Create / version ─────────────────────────────────────────────────
|
||||
|
||||
public sealed class SaveInventionV4Request
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public int InstantiationCost { get; set; }
|
||||
public int LightsCost { get; set; }
|
||||
public int AiCost { get; set; }
|
||||
public long? CreationRoomId { get; set; }
|
||||
public string? InventionDataFilename { get; set; }
|
||||
public List<long>? ReferencedInventions { get; set; }
|
||||
public int CreatorAccountRole { get; set; }
|
||||
}
|
||||
public sealed record SaveInventionRequest(
|
||||
string Name, string? Description, string? ImageName,
|
||||
int? Permission, string? Tags, string BlobName,
|
||||
long? CreationRoomId, int? InstantiationCost, int? LightsCost);
|
||||
|
||||
/// <summary>POST <c>api/inventions/v4/save</c> — 2020.12 maker-pen
|
||||
/// save flow. v4 wraps the new invention plus its first version in
|
||||
/// a Status/Invention/InventionVersion object.</summary>
|
||||
[HttpPost("api/inventions/v4/save")]
|
||||
[HttpPost("api/inventions/v3/save")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
|
||||
public async Task<ActionResult> Save([FromBody] SaveInventionRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var name = (req.Name ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { Status = 1, Error = "missing name" });
|
||||
|
||||
var blobName = (req.InventionDataFilename ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest(new { Status = 1, Error = "missing inventionDataFilename" });
|
||||
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("missing name");
|
||||
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
|
||||
|
||||
var inv = new InventionEntity
|
||||
{
|
||||
CreatorPlayerId = pid,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
Name = name,
|
||||
Name = req.Name.Trim(),
|
||||
Description = req.Description ?? string.Empty,
|
||||
ImageName = req.ImageName ?? string.Empty,
|
||||
Permission = 0,
|
||||
CurrentBlobName = blobName,
|
||||
TagsCsv = string.Empty,
|
||||
Permission = Math.Clamp(req.Permission ?? 0, 0, 2),
|
||||
CurrentBlobName = req.BlobName,
|
||||
TagsCsv = req.Tags ?? string.Empty,
|
||||
CreationRoomId = req.CreationRoomId,
|
||||
CreatorPermission = 100,
|
||||
GeneralPermission = 0,
|
||||
CreatorPermission = 100, // Unlimited for the creator
|
||||
GeneralPermission = 0, // Unassigned (private) until publish
|
||||
IsPublished = false,
|
||||
CurrentVersionNumber = 1,
|
||||
};
|
||||
db.Inventions.Add(inv);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Snapshot the v1 version row.
|
||||
var v1 = new InventionVersionEntity
|
||||
{
|
||||
InventionId = inv.Id,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
VersionNumber = 1,
|
||||
BlobName = blobName,
|
||||
InstantiationCost = Math.Max(0, req.InstantiationCost),
|
||||
LightsCost = Math.Max(0, req.LightsCost),
|
||||
BlobName = req.BlobName,
|
||||
InstantiationCost = req.InstantiationCost ?? 0,
|
||||
LightsCost = req.LightsCost ?? 0,
|
||||
};
|
||||
db.InventionVersions.Add(v1);
|
||||
|
||||
if (req.ReferencedInventions is { Count: > 0 })
|
||||
{
|
||||
foreach (var referencedId in req.ReferencedInventions.Distinct().Take(200))
|
||||
{
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"invention:{inv.Id}:references:{referencedId}",
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Status = 0,
|
||||
Invention = ToWireV4(inv),
|
||||
InventionVersion = ToVersionWire(v1),
|
||||
});
|
||||
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
public sealed record AddVersionRequest(
|
||||
@@ -507,7 +419,7 @@ public class InventionsController(
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v3/publish")]
|
||||
[HttpGet("api/inventions/v2/publish")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Publish(
|
||||
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
|
||||
@@ -645,23 +557,6 @@ public class InventionsController(
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
// Wire shape verified against the 2020.12 watch's
|
||||
// OBBBPCBIMME.PPGFHEDFBEA deserializer (Cpp2IL ISIL OBBBPCBIMME.txt
|
||||
// lines 588-704). All keys read via Util.GetKey<T> are required —
|
||||
// a missing key throws KeyNotFoundException → the watch logs
|
||||
// "Received malformed RecNet response" and aborts the join/fetch.
|
||||
//
|
||||
// Required (in disasm order):
|
||||
// InventionId, ReplicationId, CreatorPlayerId, Name, Description,
|
||||
// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
|
||||
// ModifiedAt, CreatedAt, NumPlayersHaveUsedInRoom, NumDownloads,
|
||||
// CheerCount, CreatorPermission, GeneralPermission, IsAGInvention,
|
||||
// Price, HideFromPlayer.
|
||||
// Optional (Util.GetKeyOrDefault): FirstPublishedAt, CreationRoomId.
|
||||
//
|
||||
// Casing is significant — LitJson + Util.GetKey are case-sensitive.
|
||||
// In particular IsAGInvention has a capital G (matches the asset's
|
||||
// "IsAGInvention" property, not the entity column IsAgInvention).
|
||||
private static object ToWire(InventionEntity i) => new
|
||||
{
|
||||
InventionId = i.Id,
|
||||
@@ -674,7 +569,6 @@ public class InventionsController(
|
||||
i.ImageName,
|
||||
i.CurrentVersionNumber,
|
||||
i.IsPublished,
|
||||
AllowTrial = true,
|
||||
ModifiedAt = i.UpdatedAt,
|
||||
i.CreatedAt,
|
||||
i.FirstPublishedAt,
|
||||
@@ -684,13 +578,9 @@ public class InventionsController(
|
||||
i.CheerCount,
|
||||
i.CreatorPermission,
|
||||
i.GeneralPermission,
|
||||
IsAGInvention = i.IsAgInvention,
|
||||
Price = 0,
|
||||
HideFromPlayer = false,
|
||||
i.IsAgInvention,
|
||||
};
|
||||
|
||||
private static object ToWireV4(InventionEntity i) => ToWire(i);
|
||||
|
||||
private static object ToVersionWire(InventionVersionEntity v) => new
|
||||
{
|
||||
v.InventionId,
|
||||
|
||||
@@ -71,54 +71,45 @@ public class InventoryController(
|
||||
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
|
||||
/// "favorited" flag on a piece of equipment (the watch lets the
|
||||
/// player pin commonly-used tools).</summary>
|
||||
// The 2018 client posts a JSON ARRAY of equipment objects (RecNet.cs:21812)
|
||||
// — each {PrefabName, ModificationGuid, UnlockedLevel, Favorited}. Accept an
|
||||
// array OR a single object so both shapes bind. Slug = modification GUID, or
|
||||
// the prefab name when no mod is applied. Unowned items are skipped (not a
|
||||
// hard reject) so one stale entry doesn't drop the whole batch.
|
||||
[HttpPost("api/equipment/v1/update")]
|
||||
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
|
||||
public async Task<IActionResult> UpdateEquipment([FromBody] System.Text.Json.JsonElement body)
|
||||
{
|
||||
var pid = Me;
|
||||
// Catalog-backed skins use a compound slug so we can round-trip
|
||||
// the client's separate PrefabName + ModificationGuid fields.
|
||||
var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid)
|
||||
? StoreService.EquipmentSkinSlug(req.PrefabName, req.ModificationGuid)
|
||||
: req.PrefabName;
|
||||
if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier");
|
||||
var items = new List<System.Text.Json.JsonElement>();
|
||||
if (body.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
items.AddRange(body.EnumerateArray());
|
||||
else if (body.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
items.Add(body);
|
||||
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
|
||||
if (row is null && !string.IsNullOrWhiteSpace(req.ModificationGuid))
|
||||
static string? Str(System.Text.Json.JsonElement e, string k)
|
||||
=> e.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.String
|
||||
? v.GetString() : null;
|
||||
static bool Bool(System.Text.Json.JsonElement e, string k)
|
||||
=> e.TryGetProperty(k, out var v) && (v.ValueKind == System.Text.Json.JsonValueKind.True
|
||||
|| (v.ValueKind == System.Text.Json.JsonValueKind.String && v.GetString() == "true"));
|
||||
|
||||
foreach (var el in items)
|
||||
{
|
||||
// Legacy rows created before catalog-backed equipment skins
|
||||
// used the raw modification GUID as ItemSlug.
|
||||
row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.ModificationGuid);
|
||||
var slug = Str(el, "ModificationGuid") is { Length: > 0 } mod ? mod : Str(el, "PrefabName");
|
||||
if (string.IsNullOrWhiteSpace(slug)) continue;
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
|
||||
if (row is null) continue; // skip items the player doesn't own
|
||||
row.IsActive = Bool(el, "Favorited");
|
||||
}
|
||||
if (row is null && !string.IsNullOrWhiteSpace(req.PrefabName))
|
||||
{
|
||||
row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.PrefabName);
|
||||
}
|
||||
if (row is null)
|
||||
{
|
||||
// Player doesn't own this; reject rather than silently
|
||||
// creating ownership (avoids letting clients grant items
|
||||
// to themselves).
|
||||
return Forbid();
|
||||
}
|
||||
row.IsActive = req.Favorited;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Updated = true });
|
||||
}
|
||||
|
||||
// ── Consumables ──────────────────────────────────────────────────────
|
||||
|
||||
// 2020.12 client bumped the URL to v2 AND changed the response shape from
|
||||
// per-item to BulkConsumable: each entry groups N instances of the same
|
||||
// ConsumableItemDesc with parallel Ids[] / CreatedAts[] arrays. Deserializer
|
||||
// is PLHFAEBLDAE.PPGFHEDFBEA reading PASCALCASE keys: Ids, CreatedAts,
|
||||
// ConsumableItemDesc, Count, InitialCount, IsActive, ActiveDurationMinutes,
|
||||
// IsTransferable. Returning the v1 per-item shape on v2 throws "Malformed
|
||||
// Response" and the chain calls BootSequence.OINIBKFNFAJ — fatal kick.
|
||||
[HttpGet("api/consumables/v1/getUnlocked")]
|
||||
public async Task<IActionResult> GetUnlockedConsumablesV1()
|
||||
public async Task<IActionResult> GetUnlockedConsumables()
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await (from inv in db.PlayerInventory
|
||||
@@ -129,33 +120,6 @@ public class InventoryController(
|
||||
return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item)));
|
||||
}
|
||||
|
||||
[HttpGet("api/consumables/v2/getUnlocked")]
|
||||
public async Task<IActionResult> GetUnlockedConsumablesV2()
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await (from inv in db.PlayerInventory
|
||||
join item in db.StoreItems on inv.ItemSlug equals item.Slug
|
||||
where inv.PlayerId == pid
|
||||
&& ConsumableCategories.Contains(item.Category)
|
||||
select new { inv, item }).ToListAsync();
|
||||
|
||||
// Group by ConsumableItemDesc — one BulkConsumable per consumable type.
|
||||
var bulk = rows
|
||||
.GroupBy(r => StoreService.TryGetConsumableItemDesc(r.item.Slug, out var d) ? d : r.item.Slug)
|
||||
.Select(g => new
|
||||
{
|
||||
Ids = g.Select(r => r.inv.Id).ToArray(),
|
||||
CreatedAts = g.Select(r => r.inv.AcquiredAt).ToArray(),
|
||||
ConsumableItemDesc = g.Key,
|
||||
Count = g.Sum(r => r.inv.Quantity),
|
||||
InitialCount = g.Sum(r => r.inv.Quantity),
|
||||
IsActive = false,
|
||||
ActiveDurationMinutes = 0,
|
||||
IsTransferable = false,
|
||||
});
|
||||
return Ok(bulk);
|
||||
}
|
||||
|
||||
public sealed class ConsumableItemRequest
|
||||
{
|
||||
public long Id { get; set; }
|
||||
@@ -202,30 +166,17 @@ public class InventoryController(
|
||||
|
||||
// ── Wire serializers ─────────────────────────────────────────────────
|
||||
|
||||
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item)
|
||||
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item) => new
|
||||
{
|
||||
var prefabName = item.Slug;
|
||||
var modificationGuid = inv.ItemSlug;
|
||||
var rarity = 0;
|
||||
if (StoreService.TryGetEquipmentPayload(item.Slug, out var prefab, out var mod, out var skin))
|
||||
{
|
||||
prefabName = prefab;
|
||||
modificationGuid = mod;
|
||||
rarity = skin?.rarity ?? 0;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
PrefabName = prefabName,
|
||||
ModificationGuid = modificationGuid,
|
||||
PlatformMask = -1, // All
|
||||
IsPlatformLocked = false,
|
||||
FriendlyName = item.DisplayName,
|
||||
Tooltip = item.Description,
|
||||
Rarity = rarity,
|
||||
Favorited = inv.IsActive,
|
||||
};
|
||||
}
|
||||
PrefabName = item.Slug, // for store-derived items the slug doubles as prefab name
|
||||
ModificationGuid = inv.ItemSlug,
|
||||
PlatformMask = -1, // All
|
||||
IsPlatformLocked = false,
|
||||
FriendlyName = item.DisplayName,
|
||||
Tooltip = item.Description,
|
||||
Rarity = 0, // Common (no rarity column on StoreItem yet)
|
||||
Favorited = inv.IsActive,
|
||||
};
|
||||
|
||||
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
|
||||
{
|
||||
|
||||
@@ -78,15 +78,13 @@ public class LeaderboardController(
|
||||
public async Task<IActionResult> SetStat([FromBody] SetStatRequest req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
if (row is null)
|
||||
{
|
||||
row = new LeaderboardStatEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
RoomId = roomId,
|
||||
StatChannel = req.StatChannel,
|
||||
Value = req.StatValue,
|
||||
};
|
||||
@@ -105,10 +103,11 @@ public class LeaderboardController(
|
||||
// when it's zero (rare — direct stat report outside a room
|
||||
// context) fall back to the player's current presence so a
|
||||
// stray score still gets bucketed somewhere visible.
|
||||
var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0);
|
||||
if (roomId > 0)
|
||||
{
|
||||
var meta = await db.LeaderboardChannelMeta
|
||||
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == req.StatChannel);
|
||||
.FirstOrDefaultAsync(c => c.Channel == req.StatChannel);
|
||||
if (meta is null)
|
||||
{
|
||||
// First time this channel reports. Stamp a meta row so
|
||||
@@ -125,6 +124,17 @@ public class LeaderboardController(
|
||||
ValueFormat = "count",
|
||||
});
|
||||
}
|
||||
else if (meta.RoomId == 0)
|
||||
{
|
||||
// Channel registered globally (e.g. from the hardcoded
|
||||
// KnownStatChannels fallback or an unscoped admin
|
||||
// entry). Bind it to this room now that we know which
|
||||
// room it came from. Don't re-bind if it already
|
||||
// points at a different room — the admin must be the
|
||||
// one to reassign once a channel has a home.
|
||||
meta.RoomId = roomId;
|
||||
meta.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
@@ -151,22 +161,18 @@ public class LeaderboardController(
|
||||
if (pid == 0)
|
||||
return Ok(new { playerId = 0, score = 0L, rank = 0 });
|
||||
|
||||
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
if (row is null)
|
||||
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
|
||||
|
||||
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
|
||||
var betterCount = await db.LeaderboardStats
|
||||
.CountAsync(s => s.RoomId == roomId
|
||||
&& s.StatChannel == req.StatChannel
|
||||
&& (lowerIsBetter ? s.Value < row.Value : s.Value > row.Value));
|
||||
var higherCount = await db.LeaderboardStats
|
||||
.CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value);
|
||||
return Ok(new
|
||||
{
|
||||
playerId = (int)pid,
|
||||
score = row.Value,
|
||||
rank = betterCount + 1,
|
||||
rank = higherCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -188,13 +194,12 @@ public class LeaderboardController(
|
||||
{
|
||||
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
|
||||
var window = Math.Clamp(req.WindowSize, 1, 50);
|
||||
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
|
||||
|
||||
var query = db.LeaderboardStats
|
||||
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel);
|
||||
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
|
||||
var all = await query.Select(s => new { s.PlayerId, s.Value }).ToListAsync();
|
||||
var all = await db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == req.StatChannel)
|
||||
.OrderByDescending(s => s.Value)
|
||||
.Select(s => new { s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
if (all.Count == 0)
|
||||
{
|
||||
// Empty case must still come back as a SingleLeaderboard
|
||||
@@ -246,14 +251,12 @@ public class LeaderboardController(
|
||||
var end = Math.Max(start, req.RankEnd);
|
||||
var skip = start - 1;
|
||||
var take = Math.Clamp(end - skip, 1, 200);
|
||||
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
|
||||
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
|
||||
|
||||
var query = db.LeaderboardStats
|
||||
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel);
|
||||
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
|
||||
var rows = await query.Skip(skip).Take(take).ToListAsync();
|
||||
var rows = await db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == req.StatChannel)
|
||||
.OrderByDescending(s => s.Value)
|
||||
.Skip(skip).Take(take)
|
||||
.ToListAsync();
|
||||
var entries = rows.Select((s, i) => new
|
||||
{
|
||||
playerId = (int)s.PlayerId,
|
||||
@@ -268,15 +271,14 @@ public class LeaderboardController(
|
||||
/// by the watch's full-leaderboard scoreboard view.</summary>
|
||||
[HttpGet("/leaderboard/Top")]
|
||||
[HttpGet("/api/leaderboard/Top")]
|
||||
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] long roomId = 0, [FromQuery] int take = 50)
|
||||
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var resolvedRoomId = ResolveRoomId(roomId, this.CurrentPlayerId() ?? 0);
|
||||
var lowerIsBetter = await IsLowerIsBetterAsync(resolvedRoomId, channel);
|
||||
var query = db.LeaderboardStats
|
||||
.Where(s => s.RoomId == resolvedRoomId && s.StatChannel == channel);
|
||||
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
|
||||
var rows = await query.Take(take).ToListAsync();
|
||||
var rows = await db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == channel)
|
||||
.OrderByDescending(s => s.Value)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
|
||||
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
|
||||
// same Entry list for all four buckets — friends-vs-global
|
||||
@@ -296,20 +298,4 @@ public class LeaderboardController(
|
||||
NextResetUTC = DateTime.UtcNow.AddDays(7),
|
||||
});
|
||||
}
|
||||
|
||||
private long ResolveRoomId(long requestedRoomId, long playerId)
|
||||
{
|
||||
if (requestedRoomId > 0) return requestedRoomId;
|
||||
if (playerId > 0) return playerPresence.GetRoom(playerId)?.RoomId ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async Task<bool> IsLowerIsBetterAsync(long roomId, int channel)
|
||||
{
|
||||
var live = await db.LeaderboardChannelMeta
|
||||
.Where(c => c.RoomId == roomId && c.Channel == channel)
|
||||
.Select(c => (bool?)c.LowerIsBetter)
|
||||
.FirstOrDefaultAsync();
|
||||
return live ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public class MessagesController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
PrivateInstanceService privateInstances,
|
||||
PlayerPresenceService presence,
|
||||
OnlinePresenceService onlinePresence,
|
||||
ServerSettingsService serverSettings,
|
||||
ILogger<MessagesController> logger) : ControllerBase
|
||||
{
|
||||
@@ -36,12 +36,12 @@ public class MessagesController(
|
||||
[HttpGet("get")]
|
||||
[HttpGet("/api/messages/v1/get")]
|
||||
[HttpGet("/api/messages/v3/get")]
|
||||
[HttpGet("/api/messages/v1/onlineStatus")]
|
||||
public async Task<ActionResult<List<MessageDto>>> GetMessages([FromQuery] int take = 100)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var rows = await db.Messages
|
||||
.Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me)
|
||||
.Where(m => m.Type >= 0)
|
||||
// Defensive filter for the empty-body ghost rows that
|
||||
// accumulated before SendFormMessage rejected them. The
|
||||
// watch's MessagesScreenItem renders TextMessage (type 30)
|
||||
@@ -65,7 +65,7 @@ public class MessagesController(
|
||||
public async Task<ActionResult<List<MessageDto>>> Unread()
|
||||
{
|
||||
var rows = await db.Messages
|
||||
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null && m.Type >= 0)
|
||||
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null)
|
||||
// Same ghost-row filter as GetMessages above — keep the
|
||||
// unread badge count honest by hiding empty TextMessages.
|
||||
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
|
||||
@@ -82,7 +82,7 @@ public class MessagesController(
|
||||
public List<long>? MessageIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v3/delete</c> — delete one or
|
||||
/// <summary>POST <c>api/messages/v3/delete</c> — hard-delete one or
|
||||
/// more messages addressed to the caller. The 2020 watch's inbox
|
||||
/// "delete" action posts JSON <c>{"MessageIds":[8]}</c> (plural
|
||||
/// array, verified from the live trace where the body was
|
||||
@@ -90,10 +90,9 @@ public class MessagesController(
|
||||
/// some older / SPA call sites still post <c>id</c> or
|
||||
/// <c>messageId</c> as a form/query. Accept all three shapes and
|
||||
/// scope every delete to <c>RecipientPlayerId == Me</c> so the
|
||||
/// sender can't unsend. Game invites are tombstoned instead of
|
||||
/// hard-deleted because the watch accepts an invite by firing this
|
||||
/// delete request and <c>/goto/invite/{id}</c> in parallel.</summary>
|
||||
/// sender can't unsend.</summary>
|
||||
[HttpPost("/api/messages/v3/delete")]
|
||||
[HttpPost("/api/messages/v2/delete")]
|
||||
[HttpPost("/api/messages/v1/delete")]
|
||||
[Consumes("application/json")]
|
||||
public async Task<IActionResult> DeleteJson([FromBody] DeleteRequest body)
|
||||
@@ -107,6 +106,7 @@ public class MessagesController(
|
||||
}
|
||||
|
||||
[HttpPost("/api/messages/v3/delete")]
|
||||
[HttpPost("/api/messages/v2/delete")]
|
||||
[HttpPost("/api/messages/v1/delete")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> DeleteForm(
|
||||
@@ -130,27 +130,10 @@ public class MessagesController(
|
||||
{
|
||||
ids = ids.Distinct().ToList();
|
||||
if (ids.Count == 0) return BadRequest(new { error = "missing_id" });
|
||||
|
||||
var inviteRows = await db.Messages
|
||||
.Where(m => ids.Contains(m.Id)
|
||||
&& m.RecipientPlayerId == Me
|
||||
&& (m.Type == 6 || m.Type == 7))
|
||||
.ToListAsync();
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var invite in inviteRows)
|
||||
{
|
||||
invite.Type = -invite.Type;
|
||||
invite.ReadAt ??= now;
|
||||
}
|
||||
|
||||
var hardDeleted = await db.Messages
|
||||
var deleted = await db.Messages
|
||||
.Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me)
|
||||
.Where(m => m.Type != 6 && m.Type != 7 && m.Type != -6 && m.Type != -7)
|
||||
.ExecuteDeleteAsync();
|
||||
if (inviteRows.Count > 0)
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new { success = true, error = "", deleted = hardDeleted + inviteRows.Count });
|
||||
return Ok(new { success = true, error = "", deleted });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v3/markRead</c> — bulk
|
||||
@@ -297,71 +280,14 @@ public class MessagesController(
|
||||
if (playerId <= 0 || playerId == Me)
|
||||
return BadRequest(new { error = "invalid_recipient" });
|
||||
|
||||
// Resolve which room the invite is for. Two code paths:
|
||||
//
|
||||
// PRIVATE instance: the sender created an invite-only match
|
||||
// (via /goto/room/X?CreatePrivateInstance=1). The
|
||||
// roomInstanceId the watch sends maps to a registered
|
||||
// PrivateInstance and the owner or someone already inside
|
||||
// that exact live instance can invite. That keeps access
|
||||
// control intact while still letting a guest in someone
|
||||
// else's dorm invite a friend from the in-game watch.
|
||||
//
|
||||
// PUBLIC room: the sender is in a public room / a dorm /
|
||||
// any non-private room. RoomInstanceId is just
|
||||
// roomId*100_000 — synthetic, never registered. We have to
|
||||
// resolve which Room they're in via PlayerPresenceService
|
||||
// (heartbeats keep this up to date) so we can stamp RoomId
|
||||
// on the message and the recipient's /goto/invite/{N} path
|
||||
// knows where to send them. No ownership check applies for
|
||||
// public rooms.
|
||||
long resolvedRoomId;
|
||||
string roomName;
|
||||
// Only the OWNER of the private instance can invite into it.
|
||||
// Otherwise an attacker who somehow learned a roomInstanceId
|
||||
// could invite themselves (or anyone) freely.
|
||||
var inst = await privateInstances.GetAsync(roomInstanceId);
|
||||
if (inst is not null)
|
||||
{
|
||||
var senderPresence = presence.GetRoom(Me);
|
||||
var senderIsInInstance = senderPresence is not null
|
||||
&& senderPresence.RoomInstanceId == roomInstanceId;
|
||||
if (inst.OwnerPlayerId != Me && !senderIsInInstance)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[invite] from={From} tried to invite to private instance={Instance} ownedBy={Owner} while presenceInstance={PresenceInstance}; rejecting",
|
||||
Me, roomInstanceId, inst.OwnerPlayerId, senderPresence?.RoomInstanceId);
|
||||
return Forbid();
|
||||
}
|
||||
resolvedRoomId = inst.RoomId;
|
||||
roomName = !string.IsNullOrWhiteSpace(inst.Name)
|
||||
? inst.Name
|
||||
: await db.Rooms
|
||||
.Where(r => r.Id == inst.RoomId)
|
||||
.Select(r => r.Name)
|
||||
.FirstOrDefaultAsync() ?? "Private match";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Public-room path. Trust the sender's current presence —
|
||||
// the watch only fires SendInvite from the social menu
|
||||
// while you're in a room, and heartbeats refresh presence
|
||||
// every few seconds. We DON'T trust the raw roomInstanceId
|
||||
// from the form because it's user-controlled and a
|
||||
// malicious mod could send a fabricated one.
|
||||
var senderPresence = presence.GetRoom(Me);
|
||||
if (senderPresence is null || senderPresence.RoomId <= 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[invite] from={From} to={To} instance={Instance}: no private instance AND no presence room — rejecting",
|
||||
Me, playerId, roomInstanceId);
|
||||
return BadRequest(new { error = "sender_not_in_a_room", roomInstanceId });
|
||||
}
|
||||
resolvedRoomId = senderPresence.RoomId;
|
||||
roomName = !string.IsNullOrWhiteSpace(senderPresence.Name)
|
||||
? senderPresence.Name
|
||||
: await db.Rooms
|
||||
.Where(r => r.Id == senderPresence.RoomId)
|
||||
.Select(r => r.Name)
|
||||
.FirstOrDefaultAsync() ?? "Room";
|
||||
}
|
||||
if (inst is null)
|
||||
return BadRequest(new { error = "instance_not_found", roomInstanceId });
|
||||
if (inst.OwnerPlayerId != Me)
|
||||
return Forbid();
|
||||
|
||||
// Persist a real RecNet.Message.MessageType.GameInviteV2 (=6)
|
||||
// so the watch's GameInviteController calls
|
||||
@@ -374,6 +300,10 @@ public class MessagesController(
|
||||
// second backfills the JSON Body once we know it. We can't
|
||||
// pre-allocate the Id because EF Core's identity columns only
|
||||
// populate on SaveChanges.
|
||||
var roomName = await db.Rooms
|
||||
.Where(r => r.Id == inst.RoomId)
|
||||
.Select(r => r.Name)
|
||||
.FirstOrDefaultAsync() ?? "Private match";
|
||||
|
||||
var entry = new MessageEntity
|
||||
{
|
||||
@@ -383,19 +313,18 @@ public class MessagesController(
|
||||
// until we overwrite it post-save with the real JSON.
|
||||
Body = string.Empty,
|
||||
Type = 6,
|
||||
RoomId = resolvedRoomId,
|
||||
RoomId = inst.RoomId,
|
||||
};
|
||||
db.Messages.Add(entry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Only pin the invitee row for PRIVATE instances — public-room
|
||||
// invites have no registered instance to track invitees against.
|
||||
// GoToInvite handles the missing-mapping case by falling through
|
||||
// to the message's RoomId + BuildResponseAsync(roomName).
|
||||
if (inst is not null)
|
||||
{
|
||||
await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
|
||||
}
|
||||
// InviteAsync AFTER the message save so we can pin the freshly-
|
||||
// allocated Message.Id onto the invitee row's
|
||||
// LatestInviteMessageId. This is what /goto/invite/{N} falls
|
||||
// back to when the watch's accept flow has already raced
|
||||
// through DELETE /api/messages/v3/delete — the message row is
|
||||
// gone but the (instance, player) → messageId mapping survives.
|
||||
await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
|
||||
|
||||
// Body shape MUST be the JSON the watch's Message.Deserialize
|
||||
// builds RoomInviteDetails from (verified at
|
||||
@@ -492,20 +421,6 @@ public class MessagesController(
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
// NOTE: There is intentionally NO separate handler for
|
||||
// `/api/messages/v2/send`. The controller route is
|
||||
// `api/[controller]/v2`, so SendFormMessage's relative
|
||||
// `[HttpPost("send")]` already binds `api/messages/v2/send` for
|
||||
// form posts (and SendMessage binds it for JSON, disambiguated by
|
||||
// [Consumes]). A previous standalone `SendSingle` action with an
|
||||
// explicit `[HttpPost("/api/messages/v2/send")]` duplicated that
|
||||
// binding, which made every form post to v2/send match two
|
||||
// endpoints → AmbiguousMatchException (500). That broke the very
|
||||
// thing it was meant to add: the watch's PartyUpRequest /
|
||||
// "send join request" (MessageType 120), which posts the same
|
||||
// ToPlayerId/Type/Data form shape SendFormMessage already accepts.
|
||||
// Do not re-add a v2/send action; extend SendFormMessage instead.
|
||||
|
||||
/// <summary>POST <c>api/offlineinvite/v1/send</c> — send an invite
|
||||
/// message to a player who's offline; surfaces in their inbox on
|
||||
/// next login. Wire shape: form-urlencoded <c>RecipientId</c> +
|
||||
@@ -555,23 +470,17 @@ public class MessagesController(
|
||||
|
||||
/// <summary>The boot sequence asks for online friends. We
|
||||
/// derive that from the SignalR hub's connected-player set —
|
||||
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.
|
||||
///
|
||||
/// Both <c>favoriteFriendOnlineStatus</c> and the more general
|
||||
/// <c>onlineStatus</c> URLs alias to this handler since they
|
||||
/// share the wire shape; pre-2026-05-25 the onlineStatus route
|
||||
/// was wrongly aliased to GetMessages and returned the inbox
|
||||
/// list instead, breaking the friends-online HUD.</summary>
|
||||
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.</summary>
|
||||
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
|
||||
[HttpGet("/api/messages/v1/onlineStatus")]
|
||||
public async Task<ActionResult> FavoriteFriendOnlineStatus()
|
||||
{
|
||||
// Honors the global-friends toggle (everyone is a friend when on).
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me);
|
||||
var online = onlinePresence.OnlinePlayerIds().ToHashSet();
|
||||
return Ok(friendIds.Select(id => new
|
||||
{
|
||||
AccountId = id,
|
||||
Online = presence.IsRecentlyActive(id),
|
||||
Online = online.Contains(id),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -136,9 +136,12 @@ public class InGameModerationController(
|
||||
foreach (var target in ids)
|
||||
{
|
||||
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
|
||||
// Push the ModerationKick shape the 2020.12 client expects:
|
||||
// Msg.Reason must be a ModerationBlockDetail object.
|
||||
await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
|
||||
// Push a SignalR ModerationKick notification — the watch's
|
||||
// PlayerReporting.OnModerationKick handler boots the player
|
||||
// back to their dorm.
|
||||
await notifications.NotifyAsync(
|
||||
target,
|
||||
Models.Notification.PushNotificationId.ModerationKick);
|
||||
}
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
@@ -1,29 +1,76 @@
|
||||
using System.Net.WebSockets;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Notification.V2;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy raw-WebSocket notification endpoint. Replaced by the SignalR
|
||||
/// hub at <c>notify.{apex}/hub/v1</c>. Returns 410 Gone for any client
|
||||
/// that still tries the old path.
|
||||
/// api/notification/v2 — the JUNE-2018 client opens a raw WebSocket here for
|
||||
/// push notifications (RecNet.cs:48895, RecNet.IJADBHMBMPG wraps a
|
||||
/// BestHTTP.WebSocket with open/message(string)/error events). The 2020 client
|
||||
/// replaced this with the SignalR hub at notify.{apex}/hub/v1.
|
||||
///
|
||||
/// This branch targets 2018, so we ACCEPT the WebSocket and hold it open. We
|
||||
/// don't push anything yet (server-driven notifications are a later feature) —
|
||||
/// an open, quiet socket keeps the client's notification subsystem "connected"
|
||||
/// instead of erroring/retry-looping on the old 410 Gone. Incoming frames
|
||||
/// (subscribe/auth) are drained and ignored.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
public class NotificationController(DomainConfig domain) : ControllerBase
|
||||
public class NotificationController(
|
||||
DomainConfig domain,
|
||||
ILogger<NotificationController> log) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
|
||||
public async Task Connect()
|
||||
{
|
||||
error = "use_signalr_hub",
|
||||
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
|
||||
});
|
||||
if (!HttpContext.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
// Non-WS probe (or a curl check) — return an empty notification
|
||||
// list rather than 410 so HTTP callers don't treat it as an error.
|
||||
Response.StatusCode = StatusCodes.Status200OK;
|
||||
Response.ContentType = "application/json";
|
||||
await Response.WriteAsync("[]");
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/notification/hub/v1</c> — returns the
|
||||
/// SignalR hub URL the client should open. Without this the
|
||||
/// watch falls back to its own default and may try the wrong
|
||||
/// scheme. URL is built off the configured deployment apex
|
||||
/// (DORKNET_DOMAIN).</summary>
|
||||
using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync();
|
||||
log.LogInformation("[notify] 2018 client opened notification WebSocket");
|
||||
|
||||
// HANDSHAKE: on connect the 2018 client enters PerformingHandshake and
|
||||
// blocks ("Connecting to RecNet") until the SERVER sends a handshake
|
||||
// message — any valid JSON object marks the channel Connected
|
||||
// (RecNet.cs:49616-49620, JFBBMEIDBNA just needs dict != null). Without
|
||||
// it both sides wait for each other ~35s, then the client aborts the
|
||||
// channel and shows "Failed to connect to RecNet". Send {} immediately.
|
||||
var handshake = System.Text.Encoding.UTF8.GetBytes("{}");
|
||||
await ws.SendAsync(handshake, WebSocketMessageType.Text, endOfMessage: true,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
var buf = new byte[4096];
|
||||
try
|
||||
{
|
||||
while (ws.State == WebSocketState.Open)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(
|
||||
new ArraySegment<byte>(buf), HttpContext.RequestAborted);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure,
|
||||
"bye", HttpContext.RequestAborted);
|
||||
break;
|
||||
}
|
||||
// Drain & ignore subscribe/auth frames — no server push yet.
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* client navigated away */ }
|
||||
catch (WebSocketException) { /* abrupt disconnect — normal */ }
|
||||
log.LogDebug("[notify] notification WebSocket closed");
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/notification/hub/v1</c> — 2020 SignalR hub pointer
|
||||
/// (unused by the 2018 client; kept for any 2019/2020 caller).</summary>
|
||||
[HttpGet("/api/notification/hub/v1")]
|
||||
public IActionResult HubInfo() => Ok(new
|
||||
{
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
using DorkNet.Server.Auth;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Pageview;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints behind the watch's <c>COFHGNFJMOG</c> page-view + link
|
||||
/// system. The 2020.12 client fires <c>POST pageview/consume</c> on
|
||||
/// every screen change in the boot/menu flow expecting a response
|
||||
/// that tells it (a) whether there's an in-app deep-link URL to
|
||||
/// resolve and (b) how long the answer is fresh for. Returning a 404
|
||||
/// here surfaces as red toast spam in the menus and (per
|
||||
/// <c>BootSequence.OINIBKFNFAJ</c>) can short-circuit the avatar /
|
||||
/// inventory bootstrap.
|
||||
///
|
||||
/// <para>Response shape verified at
|
||||
/// <c>Cpp2IL_ISIL/.../MPHABHIMOOO.txt:45,50</c> — the watch's
|
||||
/// <c>MPHABHIMOOO.PPGFHEDFBEA(Dictionary)</c> reads two camelCase
|
||||
/// keys:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>url</c> — string. Empty means "no deep-link to load".</item>
|
||||
/// <item><c>freshnessSeconds</c> — int. How long the client can
|
||||
/// cache this response before re-polling.</item>
|
||||
/// </list>
|
||||
/// Returning a long freshness (1 hour) keeps a private server's
|
||||
/// chattiness low when there's no real linking layer.</para>
|
||||
///
|
||||
/// <para>Sibling endpoints from the same watch class
|
||||
/// (<c>actionlink/*</c>, <c>datalink/*</c>, <c>referral</c>) are out
|
||||
/// of scope for now — they fire only on explicit user share/click,
|
||||
/// not in the boot path. Add them as separate routes when they show
|
||||
/// up in the production log.</para>
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PageviewController(ILogger<PageviewController> logger) : ControllerBase
|
||||
{
|
||||
/// <summary>POST <c>pageview/consume</c> — record a screen
|
||||
/// transition. The body is empty in 2020.12.
|
||||
/// <c>Promise<MPHABHIMOOO></c> result; the watch only uses
|
||||
/// the <c>url</c> / <c>freshnessSeconds</c> fields. We
|
||||
/// intentionally return an empty url + a 1-hour freshness — the
|
||||
/// private server has no in-app deep-link system, so there's
|
||||
/// nothing to navigate to and no reason to be polled more often.</summary>
|
||||
[HttpPost("pageview/consume", Order = 10)]
|
||||
[HttpPost("/pageview/consume")]
|
||||
[HttpGet("pageview/consume", Order = 10)]
|
||||
[HttpGet("/pageview/consume")]
|
||||
public IActionResult Consume()
|
||||
{
|
||||
logger.LogDebug("[pageview] consume callerId={Caller}", this.CurrentPlayerId() ?? 0);
|
||||
return Ok(new
|
||||
{
|
||||
url = string.Empty,
|
||||
freshnessSeconds = 3600,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Compat2018;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlatformLogin.V1;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/platformlogin/v1/* — the JUNE-2018 login family.
|
||||
///
|
||||
/// The 2018 client (build 20180621_EA) logs in via a JSON body and parses the
|
||||
/// PascalCase "login envelope" {Error, Player, Token, FirstLoginOfTheDay,
|
||||
/// AnalyticsSessionId} (RecNet.cs:40014-40021). This is wholly different from
|
||||
/// the 2019/2020 OAuth password-grant flow handled by the V2 controller, so it
|
||||
/// lives in its own versioned controller. This branch targets the 2018 client;
|
||||
/// the V2 controller still serves 2019/2020 builds.
|
||||
///
|
||||
/// Bodies are read leniently from JSON OR form OR query so JsonUtility,
|
||||
/// LitJson, and form-encoded callers all bind. The single login Token IS the
|
||||
/// JWT access token — the 2018 client re-attaches it as "Bearer {Token}" on
|
||||
/// every authenticated call and refreshes it via GET v1/refreshlogin.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/platformlogin/v1")]
|
||||
public class PlatformLoginV1Controller(
|
||||
PlayerService playerService,
|
||||
AuthService authService,
|
||||
ServerSettingsService settings,
|
||||
SignupCodeService signupCodes,
|
||||
DorkNetDbContext db,
|
||||
ILogger<PlatformLoginV1Controller> logger) : ControllerBase
|
||||
{
|
||||
// POST api/platformlogin/v1/loginaccount — primary sign-in.
|
||||
[HttpPost("loginaccount")]
|
||||
public async Task<IActionResult> LoginAccount()
|
||||
{
|
||||
var p = await ReadParamsAsync();
|
||||
var username = p.Get("Username");
|
||||
var password = p.Get("Password");
|
||||
var deviceId = p.Get("DeviceId");
|
||||
var platformId = p.Get("PlatformId");
|
||||
var platform = p.GetInt("Platform");
|
||||
|
||||
PlayerEntity player;
|
||||
|
||||
// Username + password: real credential check against PasswordHash.
|
||||
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
|
||||
{
|
||||
var byName = await playerService.GetByUsernameAsync(username);
|
||||
if (byName?.PasswordHash is null ||
|
||||
!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
|
||||
return Ok(Fail("Incorrect username or password."));
|
||||
player = byName;
|
||||
await playerService.TagPlatformAsync(player.Id, platform, platformId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Device-id fallback (no password yet). Honour the signups-disabled
|
||||
// toggle: refuse before minting a brand-new account for an unknown
|
||||
// device, and stash it for /join code redemption.
|
||||
if (await settings.AreSignupsDisabledAsync()
|
||||
&& await playerService.GetByDeviceAsync(deviceId) is null)
|
||||
{
|
||||
await signupCodes.RecordPendingDeviceAsync(
|
||||
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
|
||||
return Ok(Fail("Account creation is currently disabled by the server admin."));
|
||||
}
|
||||
player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: deviceId, platform: platform, platformId: platformId,
|
||||
displayName: username);
|
||||
}
|
||||
|
||||
return Ok(BuildEnvelope(player));
|
||||
}
|
||||
|
||||
// POST api/platformlogin/v1/logincached — sign in to a remembered account
|
||||
// (the account-selection screen). Body carries PlayerId.
|
||||
[HttpPost("logincached")]
|
||||
public async Task<IActionResult> LoginCached()
|
||||
{
|
||||
var p = await ReadParamsAsync();
|
||||
var id = p.GetLong("PlayerId");
|
||||
if (id == 0) return Ok(Fail("Missing PlayerId."));
|
||||
var player = await playerService.GetByIdAsync(id);
|
||||
if (player is null) return Ok(Fail("That account no longer exists."));
|
||||
await playerService.TagPlatformAsync(player.Id, p.GetInt("Platform"), p.Get("PlatformId"));
|
||||
return Ok(BuildEnvelope(player));
|
||||
}
|
||||
|
||||
// POST api/platformlogin/v1/createaccount — create AND sign in in one call.
|
||||
[HttpPost("createaccount")]
|
||||
public async Task<IActionResult> CreateAccount()
|
||||
{
|
||||
var p = await ReadParamsAsync();
|
||||
var deviceId = p.Get("DeviceId");
|
||||
var platformId = p.Get("PlatformId");
|
||||
var platform = p.GetInt("Platform");
|
||||
|
||||
if (await settings.AreSignupsDisabledAsync()
|
||||
&& await playerService.GetByDeviceAsync(deviceId) is null)
|
||||
{
|
||||
await signupCodes.RecordPendingDeviceAsync(
|
||||
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
|
||||
return Ok(Fail("Account creation is currently disabled by the server admin."));
|
||||
}
|
||||
|
||||
var player = await playerService.CreateNewAccountAsync(
|
||||
deviceId: deviceId, platform: platform, platformId: platformId,
|
||||
displayName: p.Get("Username"));
|
||||
|
||||
// Attach birthday / email if the signup form supplied them.
|
||||
var birthdayTicks = p.GetLong("Birthday");
|
||||
var email = p.Get("Email");
|
||||
if (birthdayTicks != 0 || !string.IsNullOrEmpty(email))
|
||||
{
|
||||
var entity = await db.Players.FirstOrDefaultAsync(x => x.Id == player.Id);
|
||||
if (entity is not null)
|
||||
{
|
||||
if (birthdayTicks != 0)
|
||||
entity.Birthday = new DateTime(birthdayTicks, DateTimeKind.Utc);
|
||||
if (!string.IsNullOrEmpty(email)) entity.Email = email;
|
||||
await db.SaveChangesAsync();
|
||||
player = entity;
|
||||
}
|
||||
}
|
||||
return Ok(BuildEnvelope(player, firstLogin: true));
|
||||
}
|
||||
|
||||
// POST api/platformlogin/v1/registeraccount?Email=... — attach an email to
|
||||
// the authenticated account. Reads {Success, Message} (NOT "error").
|
||||
[HttpPost("registeraccount")]
|
||||
public async Task<IActionResult> RegisterAccount([FromQuery(Name = "Email")] string? email)
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Ok(new SuccessMessage2018 { Success = false, Message = "Not signed in." });
|
||||
var entity = await db.Players.FirstOrDefaultAsync(x => x.Id == pid);
|
||||
if (entity is null) return Ok(new SuccessMessage2018 { Success = false, Message = "Account not found." });
|
||||
if (!string.IsNullOrWhiteSpace(email)) { entity.Email = email; await db.SaveChangesAsync(); }
|
||||
return Ok(new SuccessMessage2018 { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
// GET api/platformlogin/v1/refreshlogin — mint a fresh access token from the
|
||||
// current bearer. Response is {Token} (PascalCase). The 2018 client calls
|
||||
// Application.Quit() on an empty Token, so a 401 (no body) is the correct
|
||||
// "session ended" signal.
|
||||
[HttpGet("refreshlogin")]
|
||||
public async Task<IActionResult> RefreshLogin()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return Unauthorized();
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
if (authService.ValidateToken(token) is not long pid) return Unauthorized();
|
||||
var player = await playerService.GetByIdAsync(pid);
|
||||
if (player is null) return Unauthorized();
|
||||
var (access, _) = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new { Token = access });
|
||||
}
|
||||
|
||||
// POST api/platformlogin/v1/getcachedlogins?Platform=&PlatformId= — the
|
||||
// account-selection list. Returns a BARE JSON ARRAY of Player2018 objects.
|
||||
[HttpPost("getcachedlogins")]
|
||||
[HttpGet("getcachedlogins")]
|
||||
public async Task<IActionResult> GetCachedLogins()
|
||||
{
|
||||
// The 2018 client sends Platform/PlatformId in the FORM BODY
|
||||
// (application/x-www-form-urlencoded), not the query string — so read
|
||||
// from form/query/json. Reading [FromQuery] only returned [] (empty
|
||||
// account grid + broke +profile auto-login). Verified from the wire:
|
||||
// req=Platform=0&PlatformId=76561198969189675.
|
||||
var p = await ReadParamsAsync();
|
||||
var players = await playerService.GetCachedLoginsAsync(p.GetInt("Platform"), p.Get("PlatformId"));
|
||||
return Ok(players.Select(ToPlayer2018).ToList());
|
||||
}
|
||||
|
||||
// POST api/platformlogin/v1/removecachedlogin?Platform=&PlatformId= —
|
||||
// "forget this account". No persistent cache to clear; ack with 200.
|
||||
[HttpPost("removecachedlogin")]
|
||||
public IActionResult RemoveCachedLogin() => Ok(new SuccessMessage2018 { Success = true });
|
||||
|
||||
// POST api/platformlogin/v1/logout?LoginLockToken=... — fire-and-forget.
|
||||
[HttpPost("logout")]
|
||||
public IActionResult Logout() => Ok(new SuccessMessage2018 { Success = true });
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private Login2018Response BuildEnvelope(PlayerEntity player, bool firstLogin = false)
|
||||
{
|
||||
var (access, _) = authService.GenerateTokenPair(player.Id);
|
||||
// First-login-of-the-day is cosmetic (drives a daily XP bonus); derive
|
||||
// it from LastSeenAt where we still have the pre-update value.
|
||||
var first = firstLogin || player.LastSeenAt.Date < DateTime.UtcNow.Date;
|
||||
return new Login2018Response
|
||||
{
|
||||
Error = string.Empty,
|
||||
Player = ToPlayer2018(player),
|
||||
Token = access,
|
||||
FirstLoginOfTheDay = first,
|
||||
AnalyticsSessionId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
}
|
||||
|
||||
private static Login2018Response Fail(string message) => new() { Error = message };
|
||||
|
||||
private static Player2018 ToPlayer2018(PlayerEntity p) => Player2018Mapper.From(p);
|
||||
|
||||
/// <summary>Read request params from JSON body, form, and query — merged,
|
||||
/// case-insensitive — so JsonUtility/LitJson/form/query callers all bind.</summary>
|
||||
private async Task<ParamBag> ReadParamsAsync()
|
||||
{
|
||||
var bag = new ParamBag();
|
||||
foreach (var kv in Request.Query) bag.Set(kv.Key, kv.Value.ToString());
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
try { foreach (var kv in Request.Form) bag.Set(kv.Key, kv.Value.ToString()); }
|
||||
catch { /* not a form */ }
|
||||
}
|
||||
else if (Request.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
bag.Set(prop.Name, prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => prop.Value.GetString(),
|
||||
JsonValueKind.Number => prop.Value.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null,
|
||||
});
|
||||
Request.Body.Position = 0;
|
||||
}
|
||||
catch { /* not JSON */ }
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
private sealed class ParamBag
|
||||
{
|
||||
private readonly Dictionary<string, string?> _d = new(StringComparer.OrdinalIgnoreCase);
|
||||
public void Set(string k, string? v) { if (!string.IsNullOrEmpty(v)) _d[k] = v; }
|
||||
public string? Get(string k) => _d.TryGetValue(k, out var v) ? v : null;
|
||||
public int GetInt(string k) => int.TryParse(Get(k), out var v) ? v : 0;
|
||||
public long GetLong(string k) => long.TryParse(Get(k), out var v) ? v : 0;
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,14 @@ public class PlayerCheerController(
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> Create(
|
||||
[FromForm(Name = "TargetAccountId")] long? targetAccountId,
|
||||
[FromForm(Name = "Type")] int type = 0)
|
||||
[FromForm(Name = "PlayerIdTo")] long? playerIdTo,
|
||||
[FromForm(Name = "Type")] int type = 0,
|
||||
[FromForm(Name = "CheerCategory")] int? cheerCategory = null)
|
||||
{
|
||||
if (targetAccountId is not long target || target <= 0)
|
||||
// 2018 client posts PlayerIdTo + CheerCategory (RecNet.cs); 2020 posts
|
||||
// TargetAccountId + Type. Accept both.
|
||||
type = cheerCategory ?? type;
|
||||
if ((targetAccountId ?? playerIdTo) is not long target || target <= 0)
|
||||
return Ok(new { success = false, error = "missing_target" });
|
||||
if (Me == target)
|
||||
return Ok(new { success = false, error = "cannot_cheer_self" });
|
||||
@@ -62,35 +67,20 @@ public class PlayerCheerController(
|
||||
|
||||
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
|
||||
/// a cheer category as the caller's selected badge. Stored on the
|
||||
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).
|
||||
///
|
||||
/// Form field is <c>CheerCategory</c> — NOT <c>Cheer</c>. The 2020.12
|
||||
/// watch's AIGGBBFKAKL.SetSelectedCheer (at
|
||||
/// <c>AIGGBBFKAKL.txt:1083-1084</c>) does
|
||||
/// <c>AddField("CheerCategory", value)</c> when POSTing to
|
||||
/// <c>api/PlayerCheer/v1/SetSelectedCheer</c>. A mismatched binding
|
||||
/// silently parses to 0, which the watch reads back as "no badge",
|
||||
/// so the player's pinned cheer reverts every time.
|
||||
///
|
||||
/// The posted value is the watch's PIDFCOGLFHN category enum (e.g.
|
||||
/// <c>9000</c> for a specific category) — we store it verbatim and
|
||||
/// round-trip through <see cref="Reputation.SelectedCheer"/>; the
|
||||
/// watch maps it back to the visual badge.</summary>
|
||||
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).</summary>
|
||||
[HttpPost("api/PlayerCheer/v1/SetSelectedCheer")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SetSelectedCheer(
|
||||
[FromForm(Name = "CheerCategory")] int? cheerCategory = null,
|
||||
[FromForm(Name = "Cheer")] int? legacyCheer = null)
|
||||
[FromForm(Name = "Cheer")] int cheer = 0,
|
||||
[FromForm(Name = "CheerCategory")] int? cheerCategory = null)
|
||||
{
|
||||
// Accept either field name — `CheerCategory` for 2020.12, `Cheer`
|
||||
// as a defensive fallback for older / re-skinned clients.
|
||||
var value = cheerCategory ?? legacyCheer ?? 0;
|
||||
cheer = cheerCategory ?? cheer; // 2018 sends CheerCategory
|
||||
var existing = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
|
||||
if (existing is null)
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = value.ToString() });
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = cheer.ToString() });
|
||||
else
|
||||
existing.Value = value.ToString();
|
||||
existing.Value = cheer.ToString();
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
@@ -184,19 +184,13 @@ public class PlayerEventsController(
|
||||
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
|
||||
/// caller's RSVP state for an event. <c>Response</c> is the
|
||||
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
|
||||
/// Idempotent — re-responding overwrites.
|
||||
///
|
||||
/// Wire return is a BARE int (<c>DGOPHENCPOC</c> enum value:
|
||||
/// 0=Success, plus various error codes). Returning an object
|
||||
/// here throws InvalidCastException on the 2020.12 deserialiser
|
||||
/// — verified at <c>recroom-2020-client-response-contracts.md</c>
|
||||
/// under the playerevents/v1/respond section.</summary>
|
||||
/// Idempotent — re-responding overwrites.</summary>
|
||||
[HttpPost("api/playerevents/v1/respond")]
|
||||
public async Task<IActionResult> RespondForm(
|
||||
[FromForm(Name = "EventId")] long? eventId,
|
||||
[FromForm(Name = "Response")] int response = 0)
|
||||
{
|
||||
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/);
|
||||
if (eventId is not long evt || evt <= 0) return Ok(new { success = false });
|
||||
var existing = await db.PlayerEventResponses
|
||||
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
|
||||
if (existing is null)
|
||||
@@ -204,20 +198,19 @@ public class PlayerEventsController(
|
||||
else
|
||||
existing.Response = response;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(0 /*Success*/);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
|
||||
/// the caller's RSVP row. Same bare-int wire shape as
|
||||
/// <see cref="RespondForm"/>.</summary>
|
||||
/// the caller's RSVP row.</summary>
|
||||
[HttpPost("api/playerevents/v1/deleteResponse")]
|
||||
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
|
||||
{
|
||||
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/);
|
||||
if (eventId is not long evt) return Ok(new { success = true });
|
||||
await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == Me && r.EventId == evt)
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok(0 /*Success*/);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
|
||||
@@ -306,15 +299,11 @@ public class PlayerEventsController(
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
|
||||
/// moderation report against an event. Wire response is
|
||||
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
|
||||
/// <c>{Reported:true}</c> throws on the strict deserialiser.</summary>
|
||||
[HttpPost("api/playerevents/v1/report")]
|
||||
public async Task<IActionResult> Report([FromBody] PlayerEventReportRequest req)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
|
||||
if (ev is null) return Ok(new { Success = false, Message = "no_such_event" });
|
||||
if (ev is null) return NotFound();
|
||||
var msg = (req.Details ?? string.Empty).Trim();
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
@@ -325,6 +314,6 @@ public class PlayerEventsController(
|
||||
Message = msg[..Math.Min(1000, msg.Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty });
|
||||
return Ok(new { Reported = true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,17 +25,6 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
|
||||
[Authorize]
|
||||
public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult VoteToKickReasons() => Ok(new[]
|
||||
{
|
||||
new { ReportCategory = 1, Reason = "Harassment" },
|
||||
new { ReportCategory = 2, Reason = "Inappropriate behavior" },
|
||||
new { ReportCategory = 3, Reason = "Cheating" },
|
||||
new { ReportCategory = 4, Reason = "Spam" },
|
||||
new { ReportCategory = 5, Reason = "Other" },
|
||||
});
|
||||
|
||||
[HttpPost("api/PlayerReporting/v1/report")]
|
||||
[HttpPost("api/PlayerReporting/v1/playerreport")]
|
||||
[HttpPost("api/PlayerReporting/v2/report")]
|
||||
|
||||
@@ -1,69 +1,104 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Models.Players;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Compat2018;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Players.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api/players/* for the June-2018 client. This branch is 2018-only, so every
|
||||
/// endpoint returns the 2018 <see cref="Player2018"/> shape (keys Id/Developer/
|
||||
/// JuniorProfile/… — NOT the 2020 PlayerProfile's AccountId/IsDeveloper).
|
||||
///
|
||||
/// The 2018 client mixes path versions: profile fetch + batch are v1, while
|
||||
/// search + displayname are v2. We map both. Param names follow the 2018 client
|
||||
/// (search ?name=, displayname form Name).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
[Authorize]
|
||||
public class PlayersController(PlayerService playerService, NotificationService notificationService) : ControllerBase
|
||||
{
|
||||
private long CurrentPlayerId => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("me")]
|
||||
public async Task<ActionResult<PlayerProfile>> GetSelf()
|
||||
// GET api/players/v1|v2/me
|
||||
[HttpGet("/api/players/v1/me")]
|
||||
[HttpGet("/api/players/v2/me")]
|
||||
public async Task<ActionResult<Player2018>> GetSelf()
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(PlayerService.ToProfile(player));
|
||||
return Ok(Player2018Mapper.From(player));
|
||||
}
|
||||
|
||||
[HttpGet("{id:long}")]
|
||||
public async Task<ActionResult<PlayerProfile>> GetById(long id)
|
||||
// GET api/players/v1|v2/{id} — primary profile fetch (2018 uses v1).
|
||||
[HttpGet("/api/players/v1/{id:long}")]
|
||||
[HttpGet("/api/players/v2/{id:long}")]
|
||||
public async Task<ActionResult<Player2018>> GetById(long id)
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(id);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(PlayerService.ToProfile(player));
|
||||
return Ok(Player2018Mapper.From(player));
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<List<PlayerProfile>>> Search([FromQuery] string query)
|
||||
// POST api/players/v1/list — batch profile resolution (JSON array of ids).
|
||||
[HttpPost("/api/players/v1/list")]
|
||||
[HttpPost("/api/players/v2/list")]
|
||||
public async Task<ActionResult<List<Player2018>>> GetMany([FromBody] long[]? ids)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query)) return Ok(new List<PlayerProfile>());
|
||||
var results = await playerService.SearchAsync(query);
|
||||
return Ok(results.Select(PlayerService.ToProfile).ToList());
|
||||
if (ids is null || ids.Length == 0) return Ok(new List<Player2018>());
|
||||
var players = await playerService.GetByIdsAsync(ids);
|
||||
return Ok(players.Select(Player2018Mapper.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("displayName")]
|
||||
public async Task<ActionResult<PlayerProfile>> UpdateDisplayName([FromForm] string? DisplayName)
|
||||
// GET api/players/v2/search?name= (2018) — also accept ?query= for safety.
|
||||
[HttpGet("/api/players/v1/search")]
|
||||
[HttpGet("/api/players/v2/search")]
|
||||
public async Task<ActionResult<List<Player2018>>> Search(
|
||||
[FromQuery] string? name, [FromQuery] string? query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DisplayName))
|
||||
return BadRequest("DisplayName is required");
|
||||
|
||||
var updated = await playerService.UpdateDisplayNameAsync(CurrentPlayerId, DisplayName);
|
||||
if (!updated) return NotFound();
|
||||
var term = !string.IsNullOrWhiteSpace(name) ? name : query;
|
||||
if (string.IsNullOrWhiteSpace(term)) return Ok(new List<Player2018>());
|
||||
var results = await playerService.SearchAsync(term);
|
||||
return Ok(results.Select(Player2018Mapper.From).ToList());
|
||||
}
|
||||
|
||||
// POST api/players/v2/displayname (2018 form key "Name"). Also accept
|
||||
// "DisplayName" for safety.
|
||||
[HttpPost("/api/players/v1/displayname")]
|
||||
[HttpPost("/api/players/v2/displayname")]
|
||||
public async Task<ActionResult<Player2018>> UpdateDisplayName(
|
||||
[FromForm] string? Name, [FromForm] string? DisplayName)
|
||||
{
|
||||
var newName = !string.IsNullOrWhiteSpace(Name) ? Name : DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(newName)) return BadRequest("Name is required");
|
||||
if (!await playerService.UpdateDisplayNameAsync(CurrentPlayerId, newName)) return NotFound();
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
await notificationService.AccountChanged(player!);
|
||||
return Ok(PlayerService.ToProfile(player!));
|
||||
await notificationService.NotifyAsync(CurrentPlayerId,
|
||||
Models.Notification.PushNotificationId.SubscriptionUpdateProfile, Player2018Mapper.From(player!));
|
||||
return Ok(Player2018Mapper.From(player!));
|
||||
}
|
||||
|
||||
[HttpPut("{id:long}")]
|
||||
public async Task<ActionResult<PlayerProfile>> UpdateProfile(long id, [FromBody] UpdateProfileRequest req)
|
||||
// POST api/players/v1/bio (2018 form key "Bio").
|
||||
[HttpPost("/api/players/v1/bio")]
|
||||
[HttpPost("/api/players/v2/bio")]
|
||||
public async Task<ActionResult<Player2018>> UpdateBio([FromForm] string? Bio)
|
||||
{
|
||||
if (id != CurrentPlayerId) return Forbid();
|
||||
await playerService.UpdateBioAsync(CurrentPlayerId, Bio ?? string.Empty);
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
return Ok(Player2018Mapper.From(player!));
|
||||
}
|
||||
|
||||
if (req.DisplayName is not null)
|
||||
await playerService.UpdateDisplayNameAsync(id, req.DisplayName);
|
||||
|
||||
if (req.Bio is not null)
|
||||
await playerService.UpdateBioAsync(id, req.Bio);
|
||||
|
||||
var player = await playerService.GetByIdAsync(id);
|
||||
await notificationService.AccountChanged(player!);
|
||||
return Ok(PlayerService.ToProfile(player!));
|
||||
// POST api/players/v1/avoidJuniors (form "AvoidJuniors"). Best-effort:
|
||||
// acknowledged so the toggle in the watch settings doesn't 404. (Server
|
||||
// doesn't currently filter matchmaking on this flag.)
|
||||
[HttpPost("/api/players/v1/avoidJuniors")]
|
||||
[HttpPut("/api/players/v1/avoidJuniors")]
|
||||
public async Task<ActionResult<Player2018>> AvoidJuniors()
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(Player2018Mapper.From(player));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Controllers.API.Rooms.V2;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Playlists;
|
||||
|
||||
/// <summary>
|
||||
/// 2020.12 playlist CRUD surface — every <c>playlists/*</c> route the
|
||||
/// watch hits beyond the basic listing/detail reads in
|
||||
/// <see cref="RoomsController"/>. URL templates are bare (no
|
||||
/// <c>/roomserver/</c> prefix) because the 2020.12 client emits them
|
||||
/// that way on the Rooms host; the same routes are NOT mirrored under
|
||||
/// <c>/roomserver/</c> here to avoid duplicating definitions that already
|
||||
/// exist on <c>RoomsController</c>.
|
||||
///
|
||||
/// Wire shape conventions:
|
||||
/// • Single mutation responses use <c>BMFAGMFKODA</c> — the union
|
||||
/// <c>KMKPEOGJDFK</c> entry + <c>Rooms</c> + <c>Tags</c> arrays
|
||||
/// (same shape that <c>PlaylistDetails</c> in <see cref="RoomsController"/>
|
||||
/// returns). See <see cref="BuildDetailsResponseAsync"/>.
|
||||
/// • List responses (cheeredby/me, createdby/me, etc.) use bare
|
||||
/// <c>List<KMKPEOGJDFK></c> — the union entry alone.
|
||||
/// • Bookmark / cheer toggles return <c>{success: true}</c>; the
|
||||
/// watch's reads come back through <c>interactionby/me</c>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PlaylistsController(
|
||||
PlaylistService playlists,
|
||||
DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
// ── Create / delete ──────────────────────────────────────────────
|
||||
|
||||
public sealed class CreatePlaylistRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("/playlists")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Create([FromBody] CreatePlaylistRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest(new { error = "missing_name" });
|
||||
var created = await playlists.CreateAsync(Me, req.Name, req.Description, req.ImageName, req.Tags);
|
||||
return await BuildDetailsResponseAsync(created.Id);
|
||||
}
|
||||
|
||||
[HttpDelete("/playlists/{playlistId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete(long playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ok = await playlists.DeleteAsync(playlistId, Me);
|
||||
return ok ? Ok() : NotFound();
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return Forbid(); }
|
||||
}
|
||||
|
||||
// ── Per-field mutations (each returns BMFAGMFKODA) ───────────────
|
||||
|
||||
public sealed class StringFieldRequest { public string? Value { get; set; } }
|
||||
public sealed class IntFieldRequest { public int? Value { get; set; } }
|
||||
public sealed class BoolFieldRequest { public bool? Value { get; set; } }
|
||||
public sealed class TagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/name")]
|
||||
[HttpPut("/playlists/{playlistId:long}/name")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PlaylistName(long playlistId, [FromBody] StringFieldRequest req,
|
||||
[FromForm(Name = "Name")] string? formValue) =>
|
||||
ApplyMutation(playlistId, p => p.Name = (req?.Value ?? formValue ?? p.Name).Trim());
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/description")]
|
||||
[HttpPut("/playlists/{playlistId:long}/description")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PlaylistDescription(long playlistId, [FromBody] StringFieldRequest req,
|
||||
[FromForm(Name = "Description")] string? formValue) =>
|
||||
ApplyMutation(playlistId, p => p.Description = req?.Value ?? formValue ?? p.Description);
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/image")]
|
||||
[HttpPut("/playlists/{playlistId:long}/image")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PlaylistImage(long playlistId, [FromBody] StringFieldRequest req,
|
||||
[FromForm(Name = "ImageName")] string? formValue) =>
|
||||
ApplyMutation(playlistId, p => p.ImageName = req?.Value ?? formValue ?? p.ImageName);
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/tags")]
|
||||
[HttpPut("/playlists/{playlistId:long}/tags")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PlaylistTags(long playlistId, [FromBody] TagsRequest? req,
|
||||
[FromForm(Name = "Tags")] string? formValue) =>
|
||||
ApplyMutation(playlistId, p =>
|
||||
{
|
||||
var csv = req?.Tags
|
||||
?? (req?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
|
||||
?? formValue
|
||||
?? p.TagsCsv;
|
||||
p.TagsCsv = csv;
|
||||
});
|
||||
|
||||
/// <summary>Accessibility / visibility / level-voting / restrictions /
|
||||
/// warning are persisted on the playlist row even though the 2020
|
||||
/// PlaylistEntity doesn't have dedicated columns for each — the wire
|
||||
/// shape always reports the union-entry defaults, so for now the
|
||||
/// mutation is a no-op acknowledgement that returns the unchanged
|
||||
/// playlist. Real per-field columns can be added when we have a
|
||||
/// playlist-settings UI to drive them.</summary>
|
||||
[HttpPost("/playlists/{playlistId:long}/accessibility")]
|
||||
[HttpPost("/playlists/{playlistId:long}/visibility")]
|
||||
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
|
||||
[HttpPost("/playlists/{playlistId:long}/restrictions")]
|
||||
[HttpPost("/playlists/{playlistId:long}/warning")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PlaylistAck(long playlistId) =>
|
||||
ApplyMutation(playlistId, _ => { /* no-op: field not persisted yet */ });
|
||||
|
||||
private async Task<IActionResult> ApplyMutation(long playlistId, Action<PlaylistEntity> mutator)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updated = await playlists.ModifyAsync(playlistId, Me, mutator);
|
||||
if (updated is null) return NotFound();
|
||||
return await BuildDetailsResponseAsync(playlistId);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return Forbid(); }
|
||||
}
|
||||
|
||||
// ── Member-room mutations ────────────────────────────────────────
|
||||
|
||||
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await playlists.AddRoomAsync(playlistId, roomId, Me)) return NotFound();
|
||||
return await BuildDetailsResponseAsync(playlistId);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return Forbid(); }
|
||||
}
|
||||
|
||||
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await playlists.RemoveRoomAsync(playlistId, roomId, Me)) return NotFound();
|
||||
return await BuildDetailsResponseAsync(playlistId);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return Forbid(); }
|
||||
}
|
||||
|
||||
// ── Cheer / favorite toggles ─────────────────────────────────────
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/interactionby/me/cheer")]
|
||||
[HttpPut("/playlists/{playlistId:long}/interactionby/me/cheer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Cheer(long playlistId)
|
||||
{
|
||||
await playlists.SetInteractionAsync(playlistId, Me, cheered: true);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("/playlists/{playlistId:long}/interactionby/me/cheer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Uncheer(long playlistId)
|
||||
{
|
||||
await playlists.SetInteractionAsync(playlistId, Me, cheered: false);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/interactionby/me/favorite")]
|
||||
[HttpPut("/playlists/{playlistId:long}/interactionby/me/favorite")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Favorite(long playlistId)
|
||||
{
|
||||
await playlists.SetInteractionAsync(playlistId, Me, favorited: true);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("/playlists/{playlistId:long}/interactionby/me/favorite")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Unfavorite(long playlistId)
|
||||
{
|
||||
await playlists.SetInteractionAsync(playlistId, Me, favorited: false);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// ── My-playlists tabs (bare-list KMKPEOGJDFK) ───────────────────
|
||||
|
||||
[HttpGet("/playlists/createdby/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreatedByMe()
|
||||
{
|
||||
var rows = await playlists.CreatedByAsync(Me);
|
||||
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/playlists/cheeredby/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheeredByMe()
|
||||
{
|
||||
var rows = await playlists.CheeredByAsync(Me);
|
||||
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/playlists/favoritedby/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> FavoritedByMe()
|
||||
{
|
||||
var rows = await playlists.FavoritedByAsync(Me);
|
||||
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/playlists/visitedby/me</c> — we don't track
|
||||
/// per-player playlist visits yet (visits are tracked at the Room
|
||||
/// level via RoomVisitEntity). Return empty so the watch's
|
||||
/// playlists tab renders cleanly; populated once we add a
|
||||
/// PlaylistVisitEntity or derive from member-room visits.</summary>
|
||||
[HttpGet("/playlists/visitedby/me")]
|
||||
[Authorize]
|
||||
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
|
||||
|
||||
// ── Bulk lookup ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>/playlists/bulk?id=A&id=B</c> — bulk-by-id
|
||||
/// lookup for the watch's playlist cache warmup. Bare list of
|
||||
/// KMKPEOGJDFK entries, one per resolved id (missing ids
|
||||
/// silently dropped).</summary>
|
||||
[HttpGet("/playlists/bulk")]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
var ids = Request.Query["id"].Concat(Request.Query["ids"])
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(s => long.TryParse(s, out var n) ? n : 0)
|
||||
.Where(n => n != 0)
|
||||
.ToList();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await playlists.BulkAsync(ids);
|
||||
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
|
||||
}
|
||||
|
||||
// ── Detail-response builder (BMFAGMFKODA shape) ─────────────────
|
||||
|
||||
/// <summary>
|
||||
/// BMFAGMFKODA wire shape: KMKPEOGJDFK union entry + Rooms list
|
||||
/// (List<KLCOGEIGEBJ>) + Tags list (List<DPHPFLGAICI>).
|
||||
/// Mirrors the inline logic in <see cref="RoomsController.PlaylistDetails"/>
|
||||
/// so the BARE-PATH mutation routes here return the same shape the
|
||||
/// watch's <c>PlaylistDetails.Deserialize</c> reads.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> BuildDetailsResponseAsync(long playlistId)
|
||||
{
|
||||
var p = await playlists.GetByIdAsync(playlistId);
|
||||
if (p is null) return NotFound();
|
||||
|
||||
var roomIds = await playlists.RoomIdsAsync(playlistId);
|
||||
List<RoomEntity> memberRooms = new();
|
||||
Dictionary<long, IReadOnlyList<RoomSceneEntity>> scenesByRoom = new();
|
||||
if (roomIds.Count > 0)
|
||||
{
|
||||
var roomRows = await db.Rooms.Where(r => roomIds.Contains(r.Id)).ToListAsync();
|
||||
var byId = roomRows.ToDictionary(r => r.Id);
|
||||
memberRooms = roomIds
|
||||
.Select(rid => byId.TryGetValue(rid, out var r) ? r : null)
|
||||
.Where(r => r is not null).Select(r => r!).ToList();
|
||||
|
||||
var sceneRows = await db.RoomScenes
|
||||
.Where(s => roomIds.Contains(s.RoomId))
|
||||
.OrderBy(s => s.OrderIndex).ToListAsync();
|
||||
scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
|
||||
}
|
||||
|
||||
var roomsWire = memberRooms
|
||||
.Select(r => RoomsController.BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
|
||||
.ToList();
|
||||
|
||||
var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv)
|
||||
? new List<object>()
|
||||
: p.TagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => (object)new { Type = 0, Tag = t })
|
||||
.ToList();
|
||||
|
||||
var result = RoomsController.BuildPlaylistUnionEntry(p);
|
||||
result["Rooms"] = roomsWire;
|
||||
result["Tags"] = tagsWire;
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Models.Players;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Presence;
|
||||
|
||||
/// <summary>
|
||||
/// api/presence/* — the June-2018 client's friend-presence poll (every ~30s,
|
||||
/// RecNet.cs:63249). POST api/presence/v2/list takes a JSON array of account
|
||||
/// ids and returns one <see cref="Presence2018"/> per id. There is no 2020
|
||||
/// equivalent on this host (the 2020 client used match.rec.net/player), so this
|
||||
/// controller is 2018-specific. Online state comes from
|
||||
/// <see cref="OnlinePresenceService"/>; GameSession is left null for now (the
|
||||
/// client renders "online" without it and fills in room info on join).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PresenceController(OnlinePresenceService presence) : ControllerBase
|
||||
{
|
||||
[HttpPost("/api/presence/v2/list")]
|
||||
[HttpPost("/api/presence/v1/list")]
|
||||
public ActionResult<List<Presence2018>> List([FromBody] long[]? ids)
|
||||
{
|
||||
if (ids is null || ids.Length == 0) return Ok(new List<Presence2018>());
|
||||
var online = presence.OnlinePlayerIds().ToHashSet();
|
||||
var result = ids.Distinct().Select(id => new Presence2018
|
||||
{
|
||||
PlayerId = id,
|
||||
IsOnline = online.Contains(id),
|
||||
GameSession = null,
|
||||
}).ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ namespace DorkNet.Server.Controllers.API.ProgressionApi;
|
||||
public class ProgressionController(
|
||||
DorkNetDbContext db,
|
||||
LevelService level,
|
||||
ServerSettingsService serverSettings) : ControllerBase
|
||||
ServerSettingsService serverSettings,
|
||||
StoreService store) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
private long? MeOrNull => this.CurrentPlayerId();
|
||||
@@ -60,16 +61,18 @@ public class ProgressionController(
|
||||
var mapId = CurrentChallengeMapId();
|
||||
|
||||
var weekly = await serverSettings.GetWeeklyChallengesAsync();
|
||||
var challengeDefs = weekly.Challenges;
|
||||
var keys = challengeDefs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray();
|
||||
var rows = pid is long me
|
||||
var defs = weekly.Challenges;
|
||||
|
||||
var keys = defs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray();
|
||||
var rows = pid is long me && keys.Length > 0
|
||||
? await db.ObjectiveProgress
|
||||
.Where(o => o.PlayerId == me && keys.Contains(o.Key))
|
||||
.ToDictionaryAsync(o => o.Key, o => o.IsCompleted)
|
||||
: new Dictionary<string, bool>();
|
||||
|
||||
bool Done(int idx) => rows.TryGetValue(keys[idx], out var v) && v;
|
||||
bool Done(int idx) => rows.TryGetValue(ChallengeProgressKey(mapId, idx), out var v) && v;
|
||||
|
||||
var reward = weekly.Reward;
|
||||
return Ok(new
|
||||
{
|
||||
ChallengeMapId = mapId,
|
||||
@@ -77,13 +80,8 @@ public class ProgressionController(
|
||||
StartAt = start,
|
||||
EndAt = start.AddDays(7),
|
||||
ServerTime = now,
|
||||
// 2020.12 ChallengeMap.Deserialize (FNEEJMCEPOL.PPGFHEDFBEA) reads
|
||||
// FallbackGiftName as a required key. Missing → KeyNotFoundException
|
||||
// → "Received malformed RecNet response" → blocks the weekly-challenge
|
||||
// promise chain at boot.
|
||||
FallbackGiftName = string.Empty,
|
||||
ChallengeThemeString = "Weekly",
|
||||
Challenges = challengeDefs.Select(c => new
|
||||
Challenges = defs.Select(c => new
|
||||
{
|
||||
ChallengeId = ChallengeId(mapId, c.Index),
|
||||
c.Name,
|
||||
@@ -92,32 +90,65 @@ public class ProgressionController(
|
||||
c.Tooltip,
|
||||
Complete = Done(c.Index),
|
||||
}),
|
||||
Gift = new
|
||||
{
|
||||
GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : mapId,
|
||||
weekly.Reward.AvatarItemDesc,
|
||||
weekly.Reward.ConsumableItemDesc,
|
||||
weekly.Reward.EquipmentPrefabName,
|
||||
weekly.Reward.EquipmentModificationGuid,
|
||||
weekly.Reward.Xp,
|
||||
weekly.Reward.Level,
|
||||
weekly.Reward.GiftContext,
|
||||
weekly.Reward.GiftRarity,
|
||||
},
|
||||
Gift = BuildGiftDto(mapId, reward),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Project a <see cref="WeeklyChallengeReward"/> onto the
|
||||
/// 2020.03 <c>ChallengeGift</c> wire shape (dump.cs ChallengeGift,
|
||||
/// TypeDefIndex 11455). <c>AvatarItemType</c> is sent as
|
||||
/// <c>Outfit</c> (0) whenever an <c>AvatarItemDesc</c> is present and
|
||||
/// <c>null</c> otherwise — the client's gift-card render binds the
|
||||
/// avatar preview through it, and an <c>AvatarItemDesc</c> with no
|
||||
/// <c>AvatarItemType</c> leaves that binding half-populated (same
|
||||
/// failure mode StoreService documents for the shop grid).</summary>
|
||||
private static object BuildGiftDto(int mapId, WeeklyChallengeReward reward) => new
|
||||
{
|
||||
GiftDropId = reward.GiftDropId != 0 ? reward.GiftDropId : mapId,
|
||||
reward.AvatarItemDesc,
|
||||
AvatarItemType = string.IsNullOrEmpty(reward.AvatarItemDesc)
|
||||
? (int?)null
|
||||
: StoreAvatarItemTypeOutfit,
|
||||
reward.ConsumableItemDesc,
|
||||
reward.EquipmentPrefabName,
|
||||
reward.EquipmentModificationGuid,
|
||||
Xp = reward.Xp,
|
||||
reward.Level,
|
||||
GiftContext = reward.GiftContext,
|
||||
GiftRarity = reward.GiftRarity,
|
||||
};
|
||||
|
||||
/// <summary>AvatarItemType.Outfit — kept in sync with
|
||||
/// <c>StoreService</c>'s constant of the same value.</summary>
|
||||
private const int StoreAvatarItemTypeOutfit = 0;
|
||||
|
||||
// ObjectiveProgress row key for a weekly challenge slot.
|
||||
private static string ChallengeProgressKey(int mapId, int index) => $"challenge:{mapId}:{index}";
|
||||
|
||||
// Stable per-map ChallengeId the watch round-trips: mapId*10 + slot
|
||||
// index. Caps the slate at 10 challenges (NormalizeWeeklyChallenges
|
||||
// enforces the same Take(10)).
|
||||
private static int ChallengeId(int mapId, int index) => mapId * 10 + index;
|
||||
|
||||
// Recover the slot index from whatever the watch echoes back. Prefer
|
||||
// the mapId*10 + index encoding; fall back to the low digit for
|
||||
// clients that send a bare id. Clamp into the configured range.
|
||||
private static int ChallengeIndex(int mapId, int challengeId, int challengeCount)
|
||||
{
|
||||
if (challengeId >= mapId * 10 && challengeId < mapId * 10 + 10)
|
||||
return Math.Clamp(challengeId - mapId * 10, 0, challengeCount - 1);
|
||||
|
||||
return Math.Clamp(Math.Abs(challengeId % 10), 0, challengeCount - 1);
|
||||
}
|
||||
|
||||
public sealed class UpdateChallengeRequest
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ChallengeMapId { get; set; }
|
||||
public int ChallengeId { get; set; }
|
||||
public int Progress { get; set; }
|
||||
public string? Config { get; set; }
|
||||
public bool? Complete { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/challenge/v1/updateProgress")]
|
||||
[HttpPost("api/challenge/v2/updateProgress")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateChallengeProgress([FromForm] UpdateChallengeRequest formReq)
|
||||
@@ -126,6 +157,7 @@ public class ProgressionController(
|
||||
var req = await ReadChallengeRequestAsync(formReq);
|
||||
var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId();
|
||||
var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id;
|
||||
|
||||
var weekly = await serverSettings.GetWeeklyChallengesAsync();
|
||||
var challengeCount = Math.Max(1, weekly.Challenges.Count);
|
||||
var index = ChallengeIndex(mapId, challengeId, challengeCount);
|
||||
@@ -137,8 +169,7 @@ public class ProgressionController(
|
||||
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
|
||||
db.ObjectiveProgress.Add(row);
|
||||
}
|
||||
var shouldComplete = req.Complete ?? true;
|
||||
if (shouldComplete && !row.IsCompleted)
|
||||
if (!row.IsCompleted)
|
||||
{
|
||||
row.IsCompleted = true;
|
||||
row.ClearedAt = DateTime.UtcNow;
|
||||
@@ -147,29 +178,36 @@ public class ProgressionController(
|
||||
|
||||
var weeklyRewarded = false;
|
||||
var allKeys = weekly.Challenges
|
||||
.Select(c => c.Index)
|
||||
.Select(i => ChallengeProgressKey(mapId, i))
|
||||
.Select(c => ChallengeProgressKey(mapId, c.Index))
|
||||
.ToArray();
|
||||
var completeCount = await db.ObjectiveProgress
|
||||
.CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted);
|
||||
if (completeCount >= allKeys.Length)
|
||||
{
|
||||
var reward = weekly.Reward;
|
||||
weeklyRewarded = await GrantRewardOnceAsync(
|
||||
pid,
|
||||
$"weekly-challenge:{mapId}",
|
||||
xp: weekly.Reward.Xp,
|
||||
tokens: weekly.Reward.Tokens,
|
||||
xp: reward.Xp,
|
||||
tokens: reward.Tokens,
|
||||
reason: $"weekly_challenge:{mapId}");
|
||||
|
||||
// Land the actual skin/consumable, not just XP + tokens — the
|
||||
// December build only granted currency, so configured item
|
||||
// rewards showed on the watch but never reached the player's
|
||||
// inventory. Gated behind the same once-only sentinel so a
|
||||
// re-submit doesn't re-grant: only grant the item on the boot
|
||||
// transition where currency was also granted.
|
||||
if (weeklyRewarded && !string.IsNullOrWhiteSpace(reward.Slug))
|
||||
await store.GrantItemFreeBySlugAsync(pid, reward.Slug);
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ChallengeMapId = mapId,
|
||||
ChallengeId = challengeId,
|
||||
Config = req.Config ?? string.Empty,
|
||||
Complete = row.IsCompleted,
|
||||
Progress = Math.Max(req.Progress, row.IsCompleted ? 10 : 0),
|
||||
IsCompleted = row.IsCompleted,
|
||||
Progress = Math.Max(req.Progress, 10),
|
||||
IsCompleted = true,
|
||||
ClearedAt = row.ClearedAt,
|
||||
Rewarded = weeklyRewarded,
|
||||
});
|
||||
@@ -282,13 +320,13 @@ public class ProgressionController(
|
||||
var isRewarded = await IsRewardedAsync(pid, rewardKey);
|
||||
return Ok(new
|
||||
{
|
||||
// 2020.12 NMEKJOABIFC.PPGFHEDFBEA: HasClaimedReward (not IsRewarded).
|
||||
req.Index,
|
||||
req.Group,
|
||||
req.Index,
|
||||
Progress = row.IsCompleted ? 1f : req.Progress,
|
||||
VisualProgress = row.IsCompleted ? 1f : req.Progress,
|
||||
IsCompleted = row.IsCompleted,
|
||||
HasClaimedReward = isRewarded,
|
||||
IsRewarded = isRewarded,
|
||||
IsDirty = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -349,10 +387,6 @@ public class ProgressionController(
|
||||
.Select(o =>
|
||||
{
|
||||
var (grp, idx) = ParseObjectiveKey(o.Key);
|
||||
// 2020.12 NMEKJOABIFC.PPGFHEDFBEA reads Index, Group, Progress,
|
||||
// VisualProgress, IsCompleted, HasClaimedReward (NOT IsRewarded).
|
||||
// Missing HasClaimedReward → KeyNotFoundException → "Couldn't
|
||||
// download progress data" → BootSequence kick to LogoutScene.
|
||||
return new
|
||||
{
|
||||
Index = idx,
|
||||
@@ -360,7 +394,8 @@ public class ProgressionController(
|
||||
Progress = o.IsCompleted ? 1f : 0f,
|
||||
VisualProgress = o.IsCompleted ? 1f : 0f,
|
||||
IsCompleted = o.IsCompleted,
|
||||
HasClaimedReward = rewardKeys.Contains(o.Key),
|
||||
IsRewarded = rewardKeys.Contains(o.Key),
|
||||
IsDirty = false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -415,18 +450,10 @@ public class ProgressionController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/players/v1/objectives</c> +
|
||||
/// <c>api/players/v2/objectives</c> — batched ProgressionManager
|
||||
/// completion records (the watch's
|
||||
/// <c>ACJLCBNBJDK.EIFPPIPOIHB(List<PIHHFPDKFAG>)</c>; body is a
|
||||
/// JSON array of <c>{objectiveType: int, additionalXp: int,
|
||||
/// inParty: bool}</c>). Grants XP + RecCenter tokens via
|
||||
/// <see cref="RewardForObjectiveType"/> and dedupes per UTC day
|
||||
/// through <see cref="GrantRewardOnceAsync"/>, so a re-submission of
|
||||
/// the same objective on the same day is a no-op. Returns the
|
||||
/// granted XP / currency + post-grant balance so the watch UI can
|
||||
/// refresh without a follow-up GET.</summary>
|
||||
[HttpPost("api/players/v1/objectives")]
|
||||
/// <summary>POST <c>api/players/v2/objectives</c> — batched
|
||||
/// ProgressionManager completion records. These are separate from
|
||||
/// ObjectiveProgress rows: they grant XP/currency for daily
|
||||
/// objective events and must be idempotent per UTC day.</summary>
|
||||
[HttpPost("api/players/v2/objectives")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CompletePlayerObjectives([FromBody] List<PlayerObjectiveCompletionRecord>? records)
|
||||
@@ -478,18 +505,6 @@ public class ProgressionController(
|
||||
private static int CurrentChallengeMapId() =>
|
||||
(int)((DateTime.UtcNow - new DateTime(2020, 1, 1)).Days / 7 % 52 + 1);
|
||||
|
||||
private static string ChallengeProgressKey(int mapId, int index) => $"challenge:{mapId}:{index}";
|
||||
|
||||
private static int ChallengeId(int mapId, int index) => mapId * 10 + index;
|
||||
|
||||
private static int ChallengeIndex(int mapId, int challengeId, int challengeCount)
|
||||
{
|
||||
if (challengeId >= mapId * 10 && challengeId < mapId * 10 + 10)
|
||||
return Math.Clamp(challengeId - mapId * 10, 0, challengeCount - 1);
|
||||
|
||||
return Math.Clamp(Math.Abs(challengeId % 10), 0, challengeCount - 1);
|
||||
}
|
||||
|
||||
private async Task<UpdateChallengeRequest> ReadChallengeRequestAsync(UpdateChallengeRequest formReq)
|
||||
{
|
||||
if (Request.HasFormContentType || formReq.Id != 0 || formReq.ChallengeId != 0 || formReq.ChallengeMapId != 0)
|
||||
|
||||
@@ -340,41 +340,27 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
||||
// Watch URLs: "api/relationships/v1/favorite?id={N}" and
|
||||
// "api/relationships/v1/unfavorite?id={N}". These return a
|
||||
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
|
||||
// disassembly, not a Relationship. The flag is persisted on the caller's
|
||||
// directional row and surfaced back through v2/get + personaldetails
|
||||
// (see BuildMerged).
|
||||
//
|
||||
// VERB: the 2020 watch sends these as GET (RecNet.Relationships
|
||||
// FavoritePlayer/UnfavoritePlayer/etc. call Core.Get — verified in the
|
||||
// readable March dump at Relationships.txt:1675/1834, and the December
|
||||
// build hits the same obfuscated Core-GET wrapper). The endpoints were
|
||||
// POST-only, so the watch's GET returned HTTP 405 ("Error favoriting
|
||||
// player: HTTP Error 405"). Accept GET (real verb) + POST (cross-build
|
||||
// safety), mirroring the friend endpoints above.
|
||||
[HttpGet("api/relationships/v1/favorite")]
|
||||
// disassembly (Core.Post … ExpectHttpStatusSuccess), not a
|
||||
// Relationship. The flag is persisted on the caller's directional row
|
||||
// and surfaced back through v2/get + personaldetails (see BuildMerged).
|
||||
[HttpPost("api/relationships/v1/favorite")]
|
||||
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
|
||||
|
||||
[HttpGet("api/relationships/v1/unfavorite")]
|
||||
[HttpPost("api/relationships/v1/unfavorite")]
|
||||
public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false);
|
||||
|
||||
[HttpGet("api/relationships/v1/mute")]
|
||||
[HttpPost("api/relationships/v1/mute")]
|
||||
public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||
=> SetPreference(id != 0 ? id : formId ?? 0, muted: true);
|
||||
|
||||
[HttpGet("api/relationships/v1/unmute")]
|
||||
[HttpPost("api/relationships/v1/unmute")]
|
||||
public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||
=> SetPreference(id != 0 ? id : formId ?? 0, muted: false);
|
||||
|
||||
[HttpGet("api/relationships/v1/ignore")]
|
||||
[HttpPost("api/relationships/v1/ignore")]
|
||||
public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: true);
|
||||
|
||||
[HttpGet("api/relationships/v1/unignore")]
|
||||
[HttpPost("api/relationships/v1/unignore")]
|
||||
public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: false);
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.RoomKeys;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
// Boot-sequence call — the 2020.12 client hits this with roomId=-1
|
||||
// before the user has authenticated. Class-level [Authorize] would
|
||||
// 401 and the client treats that as fatal, kicking back to LogoutScene.
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/roomkeys/v1/room")]
|
||||
public async Task<IActionResult> ForRoom([FromQuery] long roomId)
|
||||
{
|
||||
if (roomId <= 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.RoomKeys
|
||||
.Where(k => k.RoomId == roomId && !k.IsDeleted)
|
||||
.OrderBy(k => k.Price)
|
||||
.ThenBy(k => k.Name)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/roomkeys/v1/mine")]
|
||||
public async Task<IActionResult> Mine()
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await db.RoomKeys
|
||||
.Where(k => k.CreatorPlayerId == pid && !k.IsDeleted)
|
||||
.OrderByDescending(k => k.UpdatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomkeys/v1/create")]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var req = await ReadBodyAsync<NewRoomKeyRequest>();
|
||||
var status = ValidateText(req.Name, req.Description, req.Price);
|
||||
if (status != RoomKeyStatus.Success)
|
||||
return Ok(RoomKeyResponse(status, null));
|
||||
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
|
||||
if (room is null) return Ok(RoomKeyResponse(RoomKeyStatus.RoomDoesNotExist, null));
|
||||
if (!await CanManageRoomKeysAsync(room, Me))
|
||||
return Ok(RoomKeyResponse(RoomKeyStatus.PermissionDenied, null));
|
||||
|
||||
var activeCount = await db.RoomKeys.CountAsync(k => k.RoomId == room.Id && !k.IsDeleted);
|
||||
if (activeCount >= 100)
|
||||
return Ok(RoomKeyResponse(RoomKeyStatus.RoomKeyLimitReached, null));
|
||||
|
||||
var duplicate = await db.RoomKeys.AnyAsync(k =>
|
||||
k.RoomId == room.Id && !k.IsDeleted && k.Name == req.Name!.Trim());
|
||||
if (duplicate) return Ok(RoomKeyResponse(RoomKeyStatus.DuplicateName, null));
|
||||
|
||||
var key = new RoomKeyEntity
|
||||
{
|
||||
RoomId = room.Id,
|
||||
CreatorPlayerId = Me,
|
||||
Name = req.Name!.Trim(),
|
||||
Description = req.Description!.Trim(),
|
||||
Price = req.Price,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
};
|
||||
db.RoomKeys.Add(key);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomkeys/v1/update")]
|
||||
public async Task<IActionResult> Update()
|
||||
{
|
||||
var req = await ReadBodyAsync<UpdateRoomKeyRequest>();
|
||||
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == req.RoomKeyId && !k.IsDeleted);
|
||||
if (key is null) return Ok(RoomKeyResponse(RoomKeyStatus.DoesNotExist, null));
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == key.RoomId);
|
||||
if (room is null) return Ok(RoomKeyResponse(RoomKeyStatus.RoomDoesNotExist, null));
|
||||
if (!await CanManageRoomKeysAsync(room, Me))
|
||||
return Ok(RoomKeyResponse(RoomKeyStatus.PermissionDenied, key));
|
||||
|
||||
var nextName = req.Name?.Trim() ?? key.Name;
|
||||
var nextDescription = req.Description?.Trim() ?? key.Description;
|
||||
var nextPrice = req.Price ?? key.Price;
|
||||
var status = ValidateText(nextName, nextDescription, nextPrice);
|
||||
if (status != RoomKeyStatus.Success)
|
||||
return Ok(RoomKeyResponse(status, key));
|
||||
|
||||
var duplicate = await db.RoomKeys.AnyAsync(k =>
|
||||
k.Id != key.Id && k.RoomId == key.RoomId && !k.IsDeleted && k.Name == nextName);
|
||||
if (duplicate) return Ok(RoomKeyResponse(RoomKeyStatus.DuplicateName, key));
|
||||
|
||||
key.Name = nextName;
|
||||
key.Description = nextDescription;
|
||||
key.Price = nextPrice;
|
||||
key.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
|
||||
}
|
||||
|
||||
[HttpDelete("api/roomkeys/v1/delete/{roomKeyId:long}")]
|
||||
[HttpPost("api/roomkeys/v1/delete/{roomKeyId:long}")]
|
||||
public async Task<IActionResult> Delete(long roomKeyId)
|
||||
{
|
||||
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == roomKeyId && !k.IsDeleted);
|
||||
if (key is null) return Content(((int)RoomKeyStatus.DoesNotExist).ToString(), "application/json");
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == key.RoomId);
|
||||
if (room is null) return Content(((int)RoomKeyStatus.RoomDoesNotExist).ToString(), "application/json");
|
||||
if (!await CanManageRoomKeysAsync(room, Me))
|
||||
return Content(((int)RoomKeyStatus.PermissionDenied).ToString(), "application/json");
|
||||
|
||||
key.IsDeleted = true;
|
||||
key.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Content(((int)RoomKeyStatus.Success).ToString(), "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("api/roomkeys/v1/purchased/{roomKeyId:long}")]
|
||||
public async Task<IActionResult> Purchased(long roomKeyId)
|
||||
{
|
||||
var pid = Me;
|
||||
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
|
||||
return Content(owns ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
|
||||
{
|
||||
Status = (int)status,
|
||||
RoomKey = key is null ? null : ToWire(key),
|
||||
};
|
||||
|
||||
public static object ToWire(RoomKeyEntity key) => new
|
||||
{
|
||||
RoomKeyId = key.Id,
|
||||
ReplicationId = Guid.TryParse(key.ReplicationId, out var guid)
|
||||
? guid
|
||||
: Guid.Empty,
|
||||
key.RoomId,
|
||||
key.Name,
|
||||
key.Description,
|
||||
key.Price,
|
||||
};
|
||||
|
||||
private static RoomKeyStatus ValidateText(string? name, string? description, int price)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length < 3) return RoomKeyStatus.NameTooShort;
|
||||
if (name.Trim().Length > 40) return RoomKeyStatus.NameTooLong;
|
||||
if (string.IsNullOrWhiteSpace(description) || description.Trim().Length < 10) return RoomKeyStatus.DescriptionTooShort;
|
||||
if (description.Trim().Length > 174) return RoomKeyStatus.DescriptionTooLong;
|
||||
if (price < 10) return RoomKeyStatus.PriceTooLow;
|
||||
if (price > 10000) return RoomKeyStatus.PriceTooHigh;
|
||||
return RoomKeyStatus.Success;
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageRoomKeysAsync(RoomEntity room, long playerId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return true;
|
||||
var isCoOwner = await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted);
|
||||
if (isCoOwner) return true;
|
||||
return await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.IsAdmin)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<T> ReadBodyAsync<T>() where T : new()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
if (typeof(T) == typeof(NewRoomKeyRequest))
|
||||
{
|
||||
object req = new NewRoomKeyRequest
|
||||
{
|
||||
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
|
||||
?? form["RoomId"].FirstOrDefault(), out var roomId)
|
||||
? roomId
|
||||
: 0,
|
||||
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
|
||||
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
|
||||
Price = int.TryParse(form["price"].FirstOrDefault()
|
||||
?? form["Price"].FirstOrDefault(), out var price)
|
||||
? price
|
||||
: 0,
|
||||
};
|
||||
return (T)req;
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(UpdateRoomKeyRequest))
|
||||
{
|
||||
object req = new UpdateRoomKeyRequest
|
||||
{
|
||||
RoomKeyId = long.TryParse(form["roomKeyId"].FirstOrDefault()
|
||||
?? form["RoomKeyId"].FirstOrDefault(), out var roomKeyId)
|
||||
? roomKeyId
|
||||
: 0,
|
||||
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
|
||||
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
|
||||
Price = int.TryParse(form["price"].FirstOrDefault()
|
||||
?? form["Price"].FirstOrDefault(), out var price)
|
||||
? price
|
||||
: null,
|
||||
};
|
||||
return (T)req;
|
||||
}
|
||||
|
||||
return new T();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<T>(Request.Body, JsonOptions) ?? new T();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public sealed class NewRoomKeyRequest
|
||||
{
|
||||
[JsonPropertyName("roomId")] public long RoomId { get; set; }
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
[JsonPropertyName("price")] public int Price { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateRoomKeyRequest
|
||||
{
|
||||
[JsonPropertyName("roomKeyId")] public long RoomKeyId { get; set; }
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
[JsonPropertyName("price")] public int? Price { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public enum RoomKeyStatus
|
||||
{
|
||||
Success = 0,
|
||||
InvalidParameters = 1,
|
||||
DoesNotExist = 2,
|
||||
NameTooShort = 3,
|
||||
NameTooLong = 4,
|
||||
DuplicateName = 5,
|
||||
InappropriateName = 6,
|
||||
DescriptionTooShort = 7,
|
||||
DescriptionTooLong = 8,
|
||||
InappropriateDescription = 9,
|
||||
PriceTooLow = 10,
|
||||
PriceTooHigh = 11,
|
||||
PermissionDenied = 12,
|
||||
PlayerHasRoomUnderModerationReview = 13,
|
||||
JuniorStatusFail = 14,
|
||||
PlayerIsNotCoOwner = 15,
|
||||
RoomKeyLimitReached = 16,
|
||||
PlayerAlreadyOwns = 17,
|
||||
RoomUnderModerationReview = 18,
|
||||
PurchaseFailed = 19,
|
||||
RoomDoesNotExist = 20,
|
||||
PaidKeyPurchasingDisabled = 21,
|
||||
CreateOrModifyKeysDisabled = 22,
|
||||
RoomKeyUnderModerationReview = 23,
|
||||
PlayerRestrictedFromP2PSelling = 24,
|
||||
PlayerNotRecRoomPlusMember = 25,
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
@@ -19,7 +18,7 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2;
|
||||
/// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms,
|
||||
/// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent,
|
||||
/// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id},
|
||||
/// roomserver/rooms/{id}, v1/personaldetails/{id}, v2/personaldetails/{id}.
|
||||
/// v1/personaldetails/{id}, v2/personaldetails/{id}.
|
||||
///
|
||||
/// All endpoints serialize Rooms via RoomService.ToWireRoom which matches
|
||||
/// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required
|
||||
@@ -28,13 +27,11 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2;
|
||||
[ApiController]
|
||||
public class RoomsController(
|
||||
RoomService rooms,
|
||||
PlaylistService playlists,
|
||||
DorkNetDbContext db,
|
||||
PlayerPresenceService presence,
|
||||
OnlinePresenceService onlinePresence,
|
||||
DomainConfig domain,
|
||||
NotificationService notifications,
|
||||
ServerSettingsService serverSettings,
|
||||
ILogger<RoomsController> logger) : ControllerBase
|
||||
{
|
||||
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
|
||||
@@ -48,77 +45,30 @@ public class RoomsController(
|
||||
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/hot")]
|
||||
[HttpGet("rooms/hot")]
|
||||
public async Task<IActionResult> Hot(
|
||||
[FromQuery] string? roomScoreType,
|
||||
[FromQuery] string? tags)
|
||||
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/roomserver/rooms/recommendations</c> — the home-tab
|
||||
/// "Recommended" carousel. <b>Wire shape is NOT a flat
|
||||
/// <c>List<Room></c></b>; the watch's
|
||||
/// <c>EJDCNGBEICB.JDKLBNBIEAH</c> at <c>EJDCNGBEICB.txt:1349,1430</c>
|
||||
/// returns <c>IPromise<List<ANNKHNFLMNP>></c> and
|
||||
/// <see cref="ANNKHNFLMNP.PPGFHEDFBEA"/> at
|
||||
/// <c>ANNKHNFLMNP.txt:43-97</c> reads <c>SeedRoom</c> (sub-object,
|
||||
/// <c>KLCOGEIGEBJ</c> i.e. full Room) and <c>Rooms</c> (list of
|
||||
/// Rooms) as required keys. Returning <c>[{room}, …]</c> raised
|
||||
/// <c>KeyNotFoundException: Failed to find key 'Rooms' when
|
||||
/// deserializing object of type KLCOGEIGEBJ</c>.
|
||||
///
|
||||
/// We group the hot list into one "card" per room — that gives the
|
||||
/// watch one row per featured room with that room as its own
|
||||
/// SeedRoom and a single-element Rooms list. Cheap, semantically
|
||||
/// correct, and avoids the deserializer-crashing empty-Rooms
|
||||
/// edge case the watch hits when a group has no rooms.
|
||||
/// </summary>
|
||||
[HttpGet("roomserver/rooms/recommendations")]
|
||||
[HttpGet("rooms/recommendations")]
|
||||
public async Task<IActionResult> Recommendations(
|
||||
[FromQuery(Name = "splitTestId")] int? splitTestId,
|
||||
[FromQuery(Name = "splitTestValue")] int? splitTestValue)
|
||||
{
|
||||
var hot = await rooms.HotAsync(null, take: 12);
|
||||
if (hot.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var roomIds = hot.Select(r => r.Id).ToList();
|
||||
var sceneRows = await db.RoomScenes
|
||||
.Where(s => roomIds.Contains(s.RoomId))
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var scenesByRoom = sceneRows
|
||||
.GroupBy(s => s.RoomId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
|
||||
|
||||
var wireRooms = hot
|
||||
.Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
|
||||
.ToList();
|
||||
|
||||
// One ANNKHNFLMNP entry per room: SeedRoom = the room itself,
|
||||
// Rooms = [that room]. The watch enumerates the outer list to
|
||||
// build the row list; each row shows its SeedRoom as headline.
|
||||
var result = wireRooms.Select(room => new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["SeedRoom"] = room,
|
||||
["Rooms"] = new List<object> { room },
|
||||
}).ToList();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// `Rooms.SearchForRooms(query)` — free-text room search.
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/search")]
|
||||
[HttpGet("api/rooms/v2/search")]
|
||||
[HttpGet("rooms/search")]
|
||||
public async Task<IActionResult> Search(
|
||||
[FromQuery(Name = "query")] string? query,
|
||||
[FromQuery(Name = "value")] string? value)
|
||||
=> Ok((await rooms.SearchAsync(query ?? value ?? string.Empty))
|
||||
.Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
// The 2018 client POSTs api/rooms/v1/search with a JSON body {Name}
|
||||
// (RecNet.cs:85205) instead of a query param. Same projection.
|
||||
[HttpPost("api/rooms/v1/search")]
|
||||
[HttpPost("api/rooms/v2/search")]
|
||||
public async Task<IActionResult> SearchPost([FromBody] RoomSearch2018? body)
|
||||
=> Ok((await rooms.SearchAsync(body?.Name ?? string.Empty))
|
||||
.Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
/// <summary>GET <c>api/rooms/v2/live</c> — currently-active rooms
|
||||
/// (rooms with at least one player presence). Same shape as Hot
|
||||
/// — order by HotScore as a proxy for "live activity" since we
|
||||
@@ -133,7 +83,6 @@ public class RoomsController(
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v2/baserooms")]
|
||||
[HttpGet("api/rooms/v3/baserooms")]
|
||||
[HttpGet("rooms/base")]
|
||||
public async Task<IActionResult> BaseRooms()
|
||||
=> Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
@@ -145,30 +94,23 @@ public class RoomsController(
|
||||
// '#' at render time, so sending "#community" produces "##community" in
|
||||
// the UI. Names stored in the DB also drop the prefix.
|
||||
[HttpGet("api/rooms/v1/filters")]
|
||||
public async Task<IActionResult> Filters()
|
||||
public IActionResult Filters() => Ok(new
|
||||
{
|
||||
var tags = await serverSettings.GetPlayMenuTagsAsync();
|
||||
return Ok(new
|
||||
{
|
||||
PinnedFilters = tags.PinnedTags,
|
||||
PopularFilters = tags.PopularTags,
|
||||
});
|
||||
}
|
||||
PinnedFilters = new[] { "community", "recroomoriginal", "featured", "quest" },
|
||||
PopularFilters = new[] { "paintball", "dodgeball", "soccer", "lasertag", "discgolf" },
|
||||
});
|
||||
|
||||
[HttpGet("api/rooms/v1/tags")]
|
||||
public async Task<IActionResult> Tags()
|
||||
{
|
||||
var tags = await serverSettings.GetPlayMenuTagsAsync();
|
||||
return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
|
||||
}
|
||||
public IActionResult Tags() => Ok(new[] {
|
||||
"community", "recroomoriginal", "featured", "quest",
|
||||
"paintball", "dodgeball", "soccer", "lasertag", "discgolf",
|
||||
});
|
||||
|
||||
[HttpGet("api/rooms/v1/pinnedtags")]
|
||||
public async Task<IActionResult> PinnedTags()
|
||||
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PinnedTags);
|
||||
public IActionResult PinnedTags() => Ok(new[] { "community", "recroomoriginal", "featured" });
|
||||
|
||||
[HttpGet("api/rooms/v1/populartags")]
|
||||
public async Task<IActionResult> PopularTags()
|
||||
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags);
|
||||
public IActionResult PopularTags() => Ok(new[] { "paintball", "dodgeball", "soccer" });
|
||||
|
||||
// ── Single room lookups ──────────────────────────────────────────────
|
||||
|
||||
@@ -178,6 +120,7 @@ public class RoomsController(
|
||||
/// synthesised "Room_<name>" for unknown names so the deserializer
|
||||
/// doesn't crash on 404.
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/name/{roomName}")]
|
||||
[HttpGet("api/rooms/v2/name/{roomName}")]
|
||||
[HttpGet("api/rooms/v3/name/{roomName}")]
|
||||
public async Task<IActionResult> ByName(string roomName)
|
||||
@@ -189,9 +132,9 @@ public class RoomsController(
|
||||
/// <summary>
|
||||
/// `Rooms.GetById(roomId)` — same shape as ByName.
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v2/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v3/{roomId:long}")]
|
||||
[HttpGet("rooms/{roomId:long}")]
|
||||
public async Task<IActionResult> ById(long roomId)
|
||||
{
|
||||
var r = await rooms.GetByIdAsync(roomId);
|
||||
@@ -219,144 +162,50 @@ public class RoomsController(
|
||||
// Rooms.LocalRoom is populated and the coroutine just needs a
|
||||
// populated Scenes[0] to get a non-null LocalRoomScene.
|
||||
|
||||
/// <summary>GET <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/datahistory</c>
|
||||
/// — drives the watch's "Restore to old version" UI in the room-
|
||||
/// settings tab. Per-subroom (not per-room) — the watch builds the
|
||||
/// URL from the template
|
||||
/// <c>String.Format("rooms/{0}/subrooms/{1}/datahistory", roomId, subRoomId)</c>
|
||||
/// (verified at <c>EJDCNGBEICB.txt:1190</c>); the <c>roomserver/</c>
|
||||
/// prefix is added by RecNet's URL builder for everything on the
|
||||
/// rooms host (api.localhost proxies it through the same prefix as the
|
||||
/// rest of the rooms API — see all the other <c>roomserver/rooms/...</c>
|
||||
/// routes in this controller).
|
||||
/// <summary>GET api/rooms/v1/datahistory/{roomId} — returns the
|
||||
/// version history for a room as a list of saves. Drives the
|
||||
/// watch's "Restore to old version" UI in the room-settings tab.
|
||||
///
|
||||
/// <para><b>SubRoomId semantics:</b> the watch's 2020.12 build treats
|
||||
/// "SubRoomId" as the <see cref="RoomSceneEntity.OrderIndex"/>, NOT
|
||||
/// rec.net's long random Guid-like SubRoomId. The heartbeat reports
|
||||
/// <c>subRoom=11</c> for the 12th scene in <c>RoomDetails.SubRooms[]</c>;
|
||||
/// this endpoint must filter by OrderIndex to match. The zip-importer
|
||||
/// stamps each <c>SubRooms/<scene>/History/</c> blob's
|
||||
/// <see cref="RoomDataBlobEntity.SubRoomId"/> with the scene's
|
||||
/// OrderIndex at insert time — the rec.net SubRoomId is captured in
|
||||
/// the sidecar JSON for provenance but isn't what gets indexed.</para>
|
||||
/// The watch sometimes calls this with <c>roomId=0</c>, which we
|
||||
/// interpret as "the caller's currently-presence-tracked room"
|
||||
/// (typically their dorm). Anything else is a literal room id.
|
||||
///
|
||||
/// <para>Wire shape: <c>List<RoomDataHistoryDTO></c>. The
|
||||
/// watch's <c>DHOCPFIOKHD.PPGFHEDFBEA</c> deserializer reads EXACTLY
|
||||
/// four PascalCase keys (<c>DHOCPFIOKHD.txt:136-150</c>):
|
||||
/// <c>SubRoomId</c> (long, strict GetKey), <c>DataBlob</c> (string),
|
||||
/// <c>SavedByAccountId</c> (long, strict GetKey), <c>CreatedAt</c>
|
||||
/// (DateTime, GetKeyOrDefault). Missing strict keys throw
|
||||
/// <c>KeyNotFoundException</c> in LitJson and the watch shows the
|
||||
/// "Could not retrieve save history from server :-(" toast from
|
||||
/// <c>UIRestoreRoomDialog.txt:1970</c>. Extra keys are ignored.</para>
|
||||
///
|
||||
/// <para>Pre-2026-05 rows (and any rows the live save flow inserts,
|
||||
/// which doesn't populate SubRoomId yet) have a null SubRoomId and
|
||||
/// surface under every sub-room's history list — back-compat.</para></summary>
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
|
||||
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
|
||||
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
|
||||
/// Wire shape: <c>List<RoomDataHistoryDTO></c>. Deserializer
|
||||
/// reads EXACTLY three PascalCase keys (verified at
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Rooms_NestedType_RoomDataHistoryDTO.txt</c>):
|
||||
/// <c>RoomDataHistoryId</c> (long), <c>DataBlobName</c> (string),
|
||||
/// <c>CreatedAt</c> (DateTime). Extra/wrong keys aren't ignored —
|
||||
/// missing required keys throw <c>KeyNotFoundException</c> in
|
||||
/// LitJson and the watch shows an empty history list. Don't add
|
||||
/// helper fields here; pipe them through a separate admin endpoint
|
||||
/// if needed.</summary>
|
||||
[HttpGet("api/rooms/v1/datahistory/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v2/datahistory/{roomId:long}")]
|
||||
public async Task<IActionResult> DataHistory(long roomId)
|
||||
{
|
||||
// 0 is a sentinel — fall back to the caller's current room
|
||||
// from PlayerPresenceService.
|
||||
if (roomId == 0 && CurrentPlayerId is long pid &&
|
||||
presence.GetRoom(pid)?.RoomId is long currentId)
|
||||
{
|
||||
roomId = currentId;
|
||||
}
|
||||
if (roomId == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var blobs = await db.RoomDataBlobs
|
||||
.Where(b => b.RoomId == roomId
|
||||
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
|
||||
// Skip the canonical per-scene blob from the import — the watch
|
||||
// is asking for HISTORY, and serving the current `room_<id>_<slug>_<scene>.room`
|
||||
// back to it as an option would make `restore to current` a
|
||||
// confusing no-op entry in the list.
|
||||
.Where(b => !b.BlobName.StartsWith($"room_{roomId}_"))
|
||||
.Where(b => b.RoomId == roomId)
|
||||
.OrderByDescending(b => b.UploadedAt)
|
||||
.Take(50)
|
||||
.Select(b => new
|
||||
{
|
||||
SubRoomId = subRoomId,
|
||||
DataBlob = b.BlobName,
|
||||
SavedByAccountId = b.UploadedByPlayerId,
|
||||
RoomDataHistoryId = b.Id,
|
||||
DataBlobName = b.BlobName,
|
||||
CreatedAt = b.UploadedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(blobs);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/restoredata</c>
|
||||
/// — the watch's "Go" button in the Restore Room dialog. Body is
|
||||
/// form-urlencoded with a single <c>filename</c> field carrying the
|
||||
/// historical blob name from the datahistory list. <paramref name="subRoomId"/>
|
||||
/// is RoomSceneEntity.OrderIndex (same convention as the GET above).
|
||||
///
|
||||
/// Effect: stamp the chosen scene's DataBlobName to the historical
|
||||
/// blob and, when restoring the room's current entry scene, also
|
||||
/// update the room-level CurrentDataBlobName so a fresh /v4/details
|
||||
/// returns the restored bytes too. Then push SubscriptionUpdateRoom
|
||||
/// so any clients already cached on the room reload the scene.
|
||||
///
|
||||
/// Returns CreateModifyRoomSceneResponse — same shape as
|
||||
/// /api/rooms/v1/datahistory/restore — so the watch's
|
||||
/// ProcessCreateModifyRoomSceneResponse chain can deserialise and
|
||||
/// flip the Restore dialog out of its "applying" state.</summary>
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")]
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoomRestoreData(
|
||||
long roomId, long subRoomId, [FromForm] string? filename)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
return BadRequest(new { error = "missing_filename" });
|
||||
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found" });
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
|
||||
var blob = await db.RoomDataBlobs
|
||||
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
|
||||
if (blob is null) return NotFound(new { error = "blob_not_found", filename });
|
||||
|
||||
var sceneRows = await db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var sceneRow = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId);
|
||||
if (sceneRow is null) return NotFound(new { error = "subroom_not_found", subRoomId });
|
||||
|
||||
var restoredAt = DateTime.UtcNow;
|
||||
sceneRow.DataBlobName = filename;
|
||||
sceneRow.DataModifiedAt = restoredAt;
|
||||
|
||||
// Only stamp the room-level CurrentDataBlobName when restoring the
|
||||
// entry scene (OrderIndex 0). Restoring a non-entry sub-room
|
||||
// shouldn't rewrite what the room loads at the front door.
|
||||
if (sceneRow.OrderIndex == 0)
|
||||
{
|
||||
await db.Rooms
|
||||
.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.CurrentDataBlobName, filename)
|
||||
.SetProperty(r => r.UpdatedAt, restoredAt));
|
||||
room.CurrentDataBlobName = filename;
|
||||
room.UpdatedAt = restoredAt;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var pushRoom = room.IsDormRoom
|
||||
? CloneWithCreator(room, pid, room.CurrentDataBlobName)
|
||||
: CloneWithCreator(room, room.CreatorPlayerId, room.CurrentDataBlobName);
|
||||
var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRows, sceneRow.OrderIndex, filename);
|
||||
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
|
||||
|
||||
logger.LogInformation(
|
||||
"[rooms-restore] player={PlayerId} room={RoomId} subRoom={SubRoomId} scene={Scene} blob={Blob}",
|
||||
pid, roomId, subRoomId, sceneRow.Name, filename);
|
||||
|
||||
// Watch's response deserializer is RecNetResult<PPENFJMFPNE> —
|
||||
// the same legacy success/value/error envelope used by
|
||||
// roomserver/rooms/{id}/subrooms/{id}/data. `value` must be the
|
||||
// flattened /roomserver/rooms/{id} DTO, not the modern
|
||||
// { Room, Scenes } details payload pushed over SubscriptionUpdateRoom.
|
||||
var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRows, filename);
|
||||
return Ok(new { success = true, value = roomServerDetails, error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>POST api/rooms/v4/saveData — the watch's
|
||||
/// <c>RecNet.Rooms.UploadLocalRoomSceneData</c> save flow.
|
||||
/// Body shape mirrors <c>RecNet.Rooms+SaveRoomSceneRequest</c>
|
||||
@@ -374,12 +223,6 @@ public class RoomsController(
|
||||
[HttpPost("api/rooms/v4/saveData")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SaveData([FromBody] SaveRoomSceneRequest body)
|
||||
=> await SaveDataCore(body);
|
||||
|
||||
private async Task<IActionResult> SaveDataCore(
|
||||
SaveRoomSceneRequest body,
|
||||
long? routeRoomId = null,
|
||||
bool wrapCreateModifyResponse = false)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
|
||||
@@ -390,13 +233,13 @@ public class RoomsController(
|
||||
// on every /goto). This is the only signal we have without a
|
||||
// RoomId on the request body.
|
||||
var current = presence.GetRoom(pid);
|
||||
if (current is null && routeRoomId is null)
|
||||
if (current is null)
|
||||
{
|
||||
// No active room — without it we can't pick a target row.
|
||||
// Return a degenerate RoomScene so the deserializer doesn't
|
||||
// throw, but flag CanMatchmakeInto=false so the watch
|
||||
// doesn't think the save succeeded.
|
||||
var missingPresenceScene = new
|
||||
return Ok(new
|
||||
{
|
||||
RoomSceneId = body.RoomSceneId,
|
||||
RoomId = 0L,
|
||||
@@ -407,25 +250,14 @@ public class RoomsController(
|
||||
MaxPlayers = 8,
|
||||
CanMatchmakeInto = false,
|
||||
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
};
|
||||
return Ok(wrapCreateModifyResponse
|
||||
? new { success = true, value = missingPresenceScene, error = string.Empty }
|
||||
: missingPresenceScene);
|
||||
});
|
||||
}
|
||||
|
||||
var roomId = routeRoomId ?? current!.RoomId;
|
||||
var roomId = current.RoomId;
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found" });
|
||||
|
||||
var newBlob = body.EffectiveRoomDataFilename.Trim();
|
||||
if (string.IsNullOrWhiteSpace(newBlob))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[rooms-save] missing blob filename player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} routeRoom={RouteRoomId}",
|
||||
pid, roomId, body.RoomSceneId, routeRoomId);
|
||||
return BadRequest(new { error = "missing_room_data_filename" });
|
||||
}
|
||||
var savedAt = DateTime.UtcNow;
|
||||
var newBlob = body.RoomDataFilename?.Trim() ?? string.Empty;
|
||||
|
||||
// Multi-scene rooms have a RoomScenes row per scene. The 2020
|
||||
// client usually sends the scene's OrderIndex, but some flows
|
||||
@@ -455,33 +287,27 @@ public class RoomsController(
|
||||
db.DormStates.Add(dormState);
|
||||
}
|
||||
dormState.CurrentDataBlobName = newBlob;
|
||||
dormState.UpdatedAt = savedAt;
|
||||
dormState.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
var canSave = room.CreatorPlayerId == pid
|
||||
|| await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin)
|
||||
|| await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == room.Id &&
|
||||
r.PlayerId == pid &&
|
||||
r.Accepted &&
|
||||
r.Role == 0);
|
||||
if (!canSave) return Forbid();
|
||||
// Owned rooms: only the creator can save. Admin override
|
||||
// intentionally not included — admins should use /restore
|
||||
// when they need to roll a room back.
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
}
|
||||
|
||||
if (sceneRow is not null)
|
||||
{
|
||||
sceneRow.DataBlobName = newBlob;
|
||||
sceneRow.DataModifiedAt = savedAt;
|
||||
sceneRow.DataModifiedAt = DateTime.UtcNow;
|
||||
}
|
||||
await db.Rooms
|
||||
.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.CurrentDataBlobName, newBlob)
|
||||
.SetProperty(r => r.UpdatedAt, savedAt));
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
||||
await db.SaveChangesAsync();
|
||||
room.CurrentDataBlobName = newBlob;
|
||||
room.UpdatedAt = savedAt;
|
||||
|
||||
// The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH
|
||||
// the /upload + /saveData responses AND a SubscriptionUpdateRoom
|
||||
@@ -505,7 +331,7 @@ public class RoomsController(
|
||||
if (sceneRow is not null && scene.Id == sceneRow.Id)
|
||||
{
|
||||
scene.DataBlobName = newBlob;
|
||||
scene.DataModifiedAt = savedAt;
|
||||
scene.DataModifiedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId;
|
||||
@@ -513,48 +339,30 @@ public class RoomsController(
|
||||
logger.LogInformation(
|
||||
"[rooms-save] saveData player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} savedScene={SavedSceneId} blob={BlobName} sceneRow={SceneRowFound} scenes={SceneCount}",
|
||||
pid, roomId, body.RoomSceneId, savedSceneId, newBlob, sceneRow is not null, sceneRowsForRoom.Count);
|
||||
var activeInstanceId = current is not null && current.RoomId == roomId
|
||||
? current.RoomInstanceId
|
||||
: 0L;
|
||||
var playersInInstance = activeInstanceId == 0
|
||||
? new[] { pid }
|
||||
: onlinePresence.OnlinePlayerIds()
|
||||
.Where(playerId =>
|
||||
{
|
||||
var playerRoom = presence.GetRoom(playerId);
|
||||
return playerRoom is not null
|
||||
&& playerRoom.RoomId == roomId
|
||||
&& playerRoom.RoomInstanceId == activeInstanceId;
|
||||
})
|
||||
.Append(pid)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var playersInInstance = onlinePresence.OnlinePlayerIds()
|
||||
.Where(playerId =>
|
||||
{
|
||||
var playerRoom = presence.GetRoom(playerId);
|
||||
return playerRoom is not null
|
||||
&& playerRoom.RoomId == roomId
|
||||
&& playerRoom.RoomInstanceId == current.RoomInstanceId;
|
||||
})
|
||||
.Append(pid)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
logger.LogInformation(
|
||||
"[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}",
|
||||
pid, roomId, activeInstanceId, newBlob, playersInInstance.Length);
|
||||
if (activeInstanceId != 0)
|
||||
{
|
||||
var updatedPrivateInstances = await db.PrivateInstances
|
||||
.Where(instance => instance.Id == activeInstanceId && instance.RoomId == roomId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(instance => instance.DataBlob, newBlob));
|
||||
if (updatedPrivateInstances > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[rooms-save] updated private instance blob room={RoomId} instance={InstanceId} blob={BlobName}",
|
||||
roomId, activeInstanceId, newBlob);
|
||||
}
|
||||
}
|
||||
pid, roomId, current.RoomInstanceId, newBlob, playersInInstance.Length);
|
||||
|
||||
var updatedRoomInstances = new Dictionary<long, RoomInstanceDto>();
|
||||
foreach (var playerId in playersInInstance)
|
||||
{
|
||||
var currentPresence = presence.GetRoom(playerId);
|
||||
if (currentPresence is null && playerId == pid && current is not null)
|
||||
if (currentPresence is null && playerId == pid)
|
||||
{
|
||||
currentPresence = current;
|
||||
}
|
||||
if (currentPresence is null || currentPresence.RoomId != roomId) continue;
|
||||
if (currentPresence is null) continue;
|
||||
|
||||
currentPresence.DataBlob = newBlob;
|
||||
presence.SetRoom(playerId, currentPresence);
|
||||
@@ -596,31 +404,25 @@ public class RoomsController(
|
||||
vrMovementMode = 0,
|
||||
roomInstance = new
|
||||
{
|
||||
roomInstanceId = currentPresence.RoomInstanceId,
|
||||
roomId = currentPresence.RoomId,
|
||||
subRoomId = currentPresence.SubRoomId,
|
||||
roomInstanceType = currentPresence.RoomInstanceType,
|
||||
location = currentPresence.Location,
|
||||
dataBlob = currentPresence.DataBlob,
|
||||
eventId = currentPresence.EventId,
|
||||
clubId = currentPresence.ClubId,
|
||||
roomCode = currentPresence.RoomCode,
|
||||
photonRegionId = currentPresence.PhotonRegionId,
|
||||
photonRoomId = currentPresence.PhotonRoomId,
|
||||
name = currentPresence.NameWire,
|
||||
maxCapacity = currentPresence.MaxCapacity,
|
||||
isFull = currentPresence.IsFull,
|
||||
isPrivate = currentPresence.IsPrivate,
|
||||
isInProgress = currentPresence.IsInProgress,
|
||||
EncryptVoiceChat = currentPresence.EncryptVoiceChat,
|
||||
roomInstanceId = currentPresence.RoomInstanceId,
|
||||
roomId = currentPresence.RoomId,
|
||||
subRoomId = currentPresence.SubRoomId,
|
||||
location = currentPresence.Location,
|
||||
photonRegionId = currentPresence.PhotonRegionId,
|
||||
photonRoomId = currentPresence.PhotonRoomId,
|
||||
maxCapacity = currentPresence.MaxCapacity,
|
||||
isFull = currentPresence.IsFull,
|
||||
isPrivate = currentPresence.IsPrivate,
|
||||
isInProgress = currentPresence.IsInProgress,
|
||||
dataBlob = currentPresence.DataBlob,
|
||||
eventId = currentPresence.EventId,
|
||||
name = currentPresence.Name,
|
||||
},
|
||||
isOnline = true,
|
||||
appVersion = 20201210,
|
||||
};
|
||||
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload);
|
||||
}
|
||||
|
||||
var savedScene = new
|
||||
return Ok(new
|
||||
{
|
||||
RoomSceneId = savedSceneId,
|
||||
RoomId = roomId,
|
||||
@@ -630,31 +432,17 @@ public class RoomsController(
|
||||
DataBlobName = newBlob,
|
||||
MaxPlayers = sceneRow?.MaxPlayers ?? 8,
|
||||
CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true,
|
||||
DataModifiedAt = savedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
};
|
||||
if (wrapCreateModifyResponse)
|
||||
{
|
||||
var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob);
|
||||
return Ok(new { success = true, value = roomServerDetails, error = string.Empty });
|
||||
}
|
||||
|
||||
return Ok(savedScene);
|
||||
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
});
|
||||
}
|
||||
|
||||
public class SaveRoomSceneRequest
|
||||
{
|
||||
public long RoomSceneId { get; set; }
|
||||
public string? RoomDataFilename { get; set; }
|
||||
[System.Text.Json.Serialization.JsonPropertyName("filename")]
|
||||
public string? Filename { get; set; }
|
||||
public List<long>? InventionUsages { get; set; }
|
||||
public string? InventionUsageBase64 { get; set; }
|
||||
public CreatorActionContextDto? CreatorActionContext { get; set; }
|
||||
public long RequestPlayerId { get; set; }
|
||||
public int SaveRequestPlayerId { get; set; }
|
||||
|
||||
public string EffectiveRoomDataFilename =>
|
||||
RoomDataFilename ?? Filename ?? string.Empty;
|
||||
}
|
||||
|
||||
public class CreatorActionContextDto
|
||||
@@ -838,53 +626,8 @@ public class RoomsController(
|
||||
return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/roomserver/rooms/{id}/interactionby/me</c> — sibling of
|
||||
/// personaldetails but DIFFERENT wire shape. Per
|
||||
/// <c>EJDCNGBEICB.txt:8219,8266</c> the deserialiser is
|
||||
/// <c>CJODCLDGFCF</c>, which reads <c>Cheered</c> + <c>Favorited</c>
|
||||
/// (PascalCase, both bool, both required — verified at
|
||||
/// <c>CJODCLDGFCF.txt:118,123</c>). Sharing the
|
||||
/// <c>{IsCheering, IsBookmarked}</c> payload with personaldetails
|
||||
/// throws <c>KeyNotFoundException</c> on the watch and stalls the
|
||||
/// room-tile UI.
|
||||
/// </summary>
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/interactionby/me")]
|
||||
[HttpGet("rooms/{roomId:long}/interactionby/me")]
|
||||
public async Task<IActionResult> RoomInteractionByMe(long roomId)
|
||||
{
|
||||
var pid = CurrentPlayerId ?? 0;
|
||||
bool cheered = false, favorited = false;
|
||||
if (pid > 0)
|
||||
{
|
||||
cheered = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid);
|
||||
favorited = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid);
|
||||
}
|
||||
return Ok(new { Cheered = cheered, Favorited = favorited });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/roomserver/playlists/{id}/interactionby/me</c> — same
|
||||
/// <c>CJODCLDGFCF</c> wire shape as the room variant. Per
|
||||
/// <c>EJDCNGBEICB.txt:8600</c> the URL is
|
||||
/// <c>"playlists/{0}/interactionby/me"</c>. Reads from
|
||||
/// <see cref="PlaylistInteractionEntity"/>; an anonymous caller
|
||||
/// (no JWT) reads as (false, false) so the watch's pre-login
|
||||
/// browse renders cleanly.
|
||||
/// </summary>
|
||||
[HttpGet("roomserver/playlists/{playlistId:long}/interactionby/me")]
|
||||
[HttpGet("playlists/{playlistId:long}/interactionby/me")]
|
||||
public async Task<IActionResult> PlaylistInteractionByMe(long playlistId)
|
||||
{
|
||||
var pid = CurrentPlayerId ?? 0;
|
||||
if (pid <= 0) return Ok(new { Cheered = false, Favorited = false });
|
||||
var (cheered, favorited) = await playlists.InteractionForAsync(playlistId, pid);
|
||||
return Ok(new { Cheered = cheered, Favorited = favorited });
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v4/details/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v3/details/{roomId:long}")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}")]
|
||||
public async Task<IActionResult> Details(long roomId)
|
||||
{
|
||||
var r = await rooms.GetByIdAsync(roomId);
|
||||
@@ -897,7 +640,12 @@ public class RoomsController(
|
||||
// owner's DormStateEntity blob so they load the same room state.
|
||||
if (room.IsDormRoom)
|
||||
{
|
||||
room = await ShapeDormRoomAsync(room);
|
||||
var dormBlobName = await db.DormStates
|
||||
.Where(d => d.PlayerId == room.CreatorPlayerId)
|
||||
.Select(d => d.CurrentDataBlobName)
|
||||
.FirstOrDefaultAsync()
|
||||
?? string.Empty;
|
||||
room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName);
|
||||
}
|
||||
|
||||
var scenes = await db.RoomScenes
|
||||
@@ -907,125 +655,9 @@ public class RoomsController(
|
||||
var roomRoles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id)
|
||||
.ToListAsync();
|
||||
if (Request.Path.StartsWithSegments("/roomserver", StringComparison.OrdinalIgnoreCase))
|
||||
return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles));
|
||||
|
||||
return Ok(BuildRoomDetails(room, scenes, roles: roomRoles));
|
||||
}
|
||||
|
||||
[HttpGet("roomserver/rooms")]
|
||||
[HttpGet("rooms")]
|
||||
public async Task<IActionResult> RoomServerDetailsByName([FromQuery(Name = "name")] string? roomName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(roomName))
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
var pid = CurrentPlayerId ?? 0;
|
||||
RoomEntity? room;
|
||||
if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0)
|
||||
{
|
||||
room = await rooms.EnsurePersonalDormAsync(pid);
|
||||
}
|
||||
else
|
||||
{
|
||||
room = await rooms.GetByNameAsync(roomName);
|
||||
}
|
||||
|
||||
room ??= Synthetic(roomName);
|
||||
if (room.IsDormRoom)
|
||||
{
|
||||
// Stamp the wire Name with the requested name so the watch's
|
||||
// name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) can find it.
|
||||
room = await ShapeDormRoomAsync(room, DormNameOverride(roomName));
|
||||
}
|
||||
|
||||
var scenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == room.Id)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var roomRoles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/roomserver/rooms/bulk?name=X&name=Y</c> or
|
||||
/// <c>?id=1&id=2</c> — bulk room cache lookup. Decomp-verified
|
||||
/// in EJDCNGBEICB: the Int64 overload writes query key <c>id</c>
|
||||
/// and the string overload writes <c>name</c>. Returns the same
|
||||
/// per-room object that <c>/roomserver/rooms/{id}</c> emits;
|
||||
/// unresolved entries are silently skipped (watch handles a shorter
|
||||
/// list fine, but a 404 wedges the room browser).</summary>
|
||||
[HttpGet("roomserver/rooms/bulk")]
|
||||
[HttpGet("rooms/bulk")]
|
||||
public async Task<IActionResult> RoomServerBulkByName()
|
||||
{
|
||||
var names = Request.Query["name"].Where(n => !string.IsNullOrWhiteSpace(n)).ToArray();
|
||||
var ids = Request.Query["id"]
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.SelectMany(v => v!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (names.Length == 0 && ids.Length == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var pid = CurrentPlayerId ?? 0;
|
||||
var results = new List<object>(names.Length + ids.Length);
|
||||
var emitted = new HashSet<long>();
|
||||
foreach (var raw in names)
|
||||
{
|
||||
var name = raw!.Trim();
|
||||
RoomEntity? room;
|
||||
if (name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0)
|
||||
room = await rooms.EnsurePersonalDormAsync(pid);
|
||||
else
|
||||
room = await rooms.GetByNameAsync(name);
|
||||
|
||||
room ??= Synthetic(name);
|
||||
if (room.IsDormRoom)
|
||||
{
|
||||
// Stamp the wire Name with the requested name so the watch's
|
||||
// name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) finds it.
|
||||
room = await ShapeDormRoomAsync(room, DormNameOverride(name));
|
||||
}
|
||||
|
||||
var scenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == room.Id)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var roomRoles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id)
|
||||
.ToListAsync();
|
||||
results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles));
|
||||
emitted.Add(room.Id);
|
||||
}
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (!emitted.Add(id)) continue;
|
||||
|
||||
var room = await rooms.GetByIdAsync(id);
|
||||
if (room is null) continue;
|
||||
|
||||
if (room.IsDormRoom)
|
||||
{
|
||||
room = await ShapeDormRoomAsync(room);
|
||||
}
|
||||
|
||||
var scenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == room.Id)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var roomRoles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id)
|
||||
.ToListAsync();
|
||||
results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles));
|
||||
}
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shallow clone of a RoomEntity with a different CreatorPlayerId
|
||||
/// and (optionally) a different CurrentDataBlobName. Used to give
|
||||
@@ -1033,40 +665,11 @@ public class RoomsController(
|
||||
/// caller and pointing at the caller's own most recent save.
|
||||
/// Doesn't touch the DB — purely a response-shaping utility.
|
||||
/// </summary>
|
||||
/// <summary>When a dorm was resolved via the magic name "DormRoom",
|
||||
/// returns that requested name (verbatim, so casing matches what the
|
||||
/// watch will look the cache up by) to stamp onto the wire room.
|
||||
/// Returns null for any other request name so the dorm keeps its real
|
||||
/// "Dorm_{playerId}" name in by-id / direct-name responses.</summary>
|
||||
private static string? DormNameOverride(string requestedName) =>
|
||||
requestedName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)
|
||||
? requestedName
|
||||
: null;
|
||||
|
||||
private async Task<RoomEntity> ShapeDormRoomAsync(RoomEntity room, string? overrideName = null)
|
||||
{
|
||||
var dormBlobName = await rooms.ResolveDormDataBlobNameAsync(room.CreatorPlayerId, room.Id);
|
||||
var dormName = overrideName ?? await rooms.BuildPersonalDormDisplayNameAsync(room.CreatorPlayerId);
|
||||
return CloneWithCreator(room, room.CreatorPlayerId, dormBlobName, dormName);
|
||||
}
|
||||
|
||||
public static RoomEntity CloneWithCreator(
|
||||
RoomEntity src, long creatorId, string? overrideBlobName = null,
|
||||
string? overrideName = null) => new()
|
||||
RoomEntity src, long creatorId, string? overrideBlobName = null) => new()
|
||||
{
|
||||
Id = src.Id,
|
||||
// overrideName lets the by-name resolvers stamp the wire Name with
|
||||
// the name the client actually requested. The personal-dorm entity
|
||||
// is named "Dorm_{playerId}", but the watch resolves it via the
|
||||
// magic name "DormRoom" (HomeScreenFlow.Button_DormRoom →
|
||||
// RunJoinRoom("DormRoom") → OJMCBOKJFOF.NHBPIIGDAJP("DormRoom")).
|
||||
// That call caches the response in a Dictionary<string,Room> and
|
||||
// looks it back up by the REQUESTED string ("DormRoom"); if the
|
||||
// wire Name is "Dorm_{id}" the lookup misses → the promise rejects
|
||||
// with "No such room" → the "contact recroom.happyfox.com" toast.
|
||||
// Stamping Name="DormRoom" here makes the lookup hit, the same way
|
||||
// RecCenter (whose entity Name IS "RecCenter") already works.
|
||||
Name = overrideName ?? src.Name,
|
||||
Name = src.Name,
|
||||
Description = src.Description,
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = src.ImageName,
|
||||
@@ -1150,13 +753,11 @@ public class RoomsController(
|
||||
// no DeserializeRoomDataBlobCoroutine, the master init
|
||||
// proceeds with default-zero permissions.
|
||||
//
|
||||
// • Saved dorm / customisable rooms: DataBlobName is the
|
||||
// • Dorm / customisable rooms: DataBlobName is either the
|
||||
// latest uploaded blob name (RoomEntity.CurrentDataBlobName,
|
||||
// written by StorageController on /upload). Unsaved dorms
|
||||
// deliberately use "" so the baked DormRoom scene loads
|
||||
// without deserializing the synthetic all-perms blob. The
|
||||
// role-only default blob made December wait forever on
|
||||
// StandaloneShapeContainer big-data during first-dorm entry.
|
||||
// written by StorageController on /upload) or, before any
|
||||
// save, "room_<id>_v1.dat" — which the catch-all serves
|
||||
// from RoomDataBlobService (the all-perms default blob).
|
||||
// • AG-Original rooms: DataBlobName="" → completed-promise
|
||||
// short-circuit → no download → no leaked stale-blob
|
||||
// behaviour → master flow proceeds → spawn fires. Maker
|
||||
@@ -1169,12 +770,9 @@ public class RoomsController(
|
||||
// the all-perms default blob until their first save replaces
|
||||
// it via StorageController; the rec center / paintball / etc.
|
||||
// serve "" so the persistence flow short-circuits.
|
||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
|
||||
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
|
||||
? room.CurrentDataBlobName
|
||||
: room.IsDormRoom ? ""
|
||||
: customisable ? $"room_{room.Id}_v1.dat" : "";
|
||||
|
||||
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
|
||||
@@ -1197,7 +795,6 @@ public class RoomsController(
|
||||
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
|
||||
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
|
||||
? overrideDataBlobName
|
||||
: room.IsDormRoom ? dataBlobName
|
||||
: s.DataBlobName,
|
||||
MaxPlayers = s.MaxPlayers,
|
||||
CanMatchmakeInto = s.CanMatchmakeInto,
|
||||
@@ -1218,7 +815,7 @@ public class RoomsController(
|
||||
Name = "Home",
|
||||
IsSandbox = false,
|
||||
DataBlobName = dataBlobName,
|
||||
MaxPlayers = room.MaxCapacity,
|
||||
MaxPlayers = 8,
|
||||
CanMatchmakeInto = true,
|
||||
// Use the room's actual UpdatedAt so the watch's
|
||||
// "is my local cache stale?" check sees an accurate
|
||||
@@ -1247,6 +844,7 @@ public class RoomsController(
|
||||
// Additional grants come from RoomRoleEntity rows when supplied;
|
||||
// callers without role data (clone/myrooms/etc.) ship just the
|
||||
// implicit creator.
|
||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||
int[] PlayersIn(int role, bool accepted) => roleList
|
||||
.Where(r => r.Role == role && r.Accepted == accepted)
|
||||
.Select(r => (int)r.PlayerId)
|
||||
@@ -1276,132 +874,6 @@ public class RoomsController(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2020.12 <c>/roomserver/rooms/{id}?include=301</c> shape. The client
|
||||
/// deserializes this directly as PPENFJMFPNE, not as the older
|
||||
/// { Room, Scenes, ... } wrapper.
|
||||
/// </summary>
|
||||
public static object BuildRoomServerDetails(
|
||||
RoomEntity room,
|
||||
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
|
||||
string? overrideDataBlobName = null,
|
||||
IReadOnlyList<RoomRoleEntity>? roles = null)
|
||||
{
|
||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
|
||||
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
|
||||
? room.CurrentDataBlobName
|
||||
: room.IsDormRoom ? ""
|
||||
: customisable ? $"room_{room.Id}_v1.dat" : "";
|
||||
var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
|
||||
object[] subRooms = sceneRows is { Count: > 0 }
|
||||
? sceneRows.Select(s => (object)new
|
||||
{
|
||||
SubRoomId = (long)s.OrderIndex,
|
||||
RoomId = room.Id,
|
||||
Name = s.Name,
|
||||
DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
|
||||
? overrideDataBlobName
|
||||
: room.IsDormRoom ? dataBlobName
|
||||
: s.DataBlobName,
|
||||
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
IsSandbox = s.IsSandbox,
|
||||
MaxPlayers = s.MaxPlayers,
|
||||
Accessibility = room.Accessibility,
|
||||
UnitySceneId = s.RoomSceneLocationId,
|
||||
}).ToArray()
|
||||
: new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
SubRoomId = 0L,
|
||||
RoomId = room.Id,
|
||||
Name = "Home",
|
||||
DataBlob = dataBlobName,
|
||||
DataSavedAt = updatedAt,
|
||||
IsSandbox = false,
|
||||
MaxPlayers = 8,
|
||||
Accessibility = room.Accessibility,
|
||||
UnitySceneId = room.LocationReplicationId,
|
||||
},
|
||||
};
|
||||
|
||||
static int WireRole(int role) => role switch
|
||||
{
|
||||
0 => 30, // CoOwner
|
||||
1 => 20, // Moderator
|
||||
2 => 10, // Host
|
||||
_ => 0,
|
||||
};
|
||||
var wireRoles = new List<object>
|
||||
{
|
||||
new { AccountId = room.CreatorPlayerId, Role = 255, InvitedRole = 0 },
|
||||
};
|
||||
wireRoles.AddRange(roleList.Select(r =>
|
||||
{
|
||||
var role = WireRole(r.Role);
|
||||
return (object)new
|
||||
{
|
||||
AccountId = r.PlayerId,
|
||||
Role = r.Accepted ? role : 0,
|
||||
InvitedRole = r.Accepted ? 0 : role,
|
||||
};
|
||||
}));
|
||||
|
||||
var tags = string.IsNullOrEmpty(room.TagsCsv)
|
||||
? Array.Empty<object>()
|
||||
: room.TagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => (object)new { Type = 0, Tag = t })
|
||||
.ToArray();
|
||||
|
||||
return new
|
||||
{
|
||||
RoomId = room.Id,
|
||||
Name = room.Name,
|
||||
Description = room.Description,
|
||||
ImageName = room.ImageName,
|
||||
WarningMask = room.RoomWarningMask,
|
||||
CustomWarning = room.CustomRoomWarning,
|
||||
CreatorAccountId = room.CreatorPlayerId,
|
||||
State = room.State,
|
||||
Accessibility = room.Accessibility,
|
||||
SupportsLevelVoting = room.SupportsLevelVoting,
|
||||
IsRRO = room.IsAGRoom,
|
||||
SupportsScreens = room.SupportsScreens,
|
||||
SupportsWalkVR = room.SupportsWalkVR,
|
||||
SupportsTeleportVR = room.SupportsTeleportVR,
|
||||
SupportsVRLow = room.SupportsVRLow,
|
||||
SupportsQuest2 = true,
|
||||
SupportsMobile = room.SupportsMobile,
|
||||
SupportsJuniors = room.AllowsJuniors,
|
||||
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
Stats = new
|
||||
{
|
||||
CheerCount = room.CheerCount,
|
||||
FavoriteCount = room.FavoriteCount,
|
||||
VisitorCount = room.VisitorCount,
|
||||
VisitCount = room.VisitCount,
|
||||
},
|
||||
IsDorm = room.IsDormRoom,
|
||||
CloningAllowed = room.CloningAllowed,
|
||||
DisableMicAutoMute = room.DisableMicAutoMute,
|
||||
DisableRoomComments = false,
|
||||
EncryptVoiceChat = false,
|
||||
SubRooms = subRooms,
|
||||
Roles = wireRoles,
|
||||
LoadScreens = Array.Empty<object>(),
|
||||
PromoImages = Array.Empty<string>(),
|
||||
PromoExternalContent = Array.Empty<object>(),
|
||||
Tags = tags,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ── Clone / create ───────────────────────────────────────────────────
|
||||
|
||||
@@ -1439,21 +911,16 @@ public class RoomsController(
|
||||
public long RoomId { get; set; }
|
||||
}
|
||||
|
||||
// 2018 POST api/rooms/v1/search body {Name} (RecNet.cs:85205).
|
||||
public class RoomSearch2018
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
// ── My-Rooms tabs ────────────────────────────────────────────────────
|
||||
|
||||
// 2020.12 OJMCBOKJFOF populates its local rooms cache from these
|
||||
// URLs at boot (verified via metadata strings: "rooms/createdby/me",
|
||||
// "rooms/moderatedby/me", "rooms/visitedby/me"). The 2020.03 client
|
||||
// used /api/rooms/v2/myrooms, while 2020.12 moved the calls under
|
||||
// /roomserver/rooms/*. Keep both route families on the same handlers.
|
||||
[HttpGet("api/rooms/v1/myrooms")]
|
||||
[HttpGet("api/rooms/v2/myrooms")]
|
||||
[HttpGet("api/rooms/v1/createdby/me")]
|
||||
[HttpGet("api/rooms/v2/createdby/me")]
|
||||
[HttpGet("api/rooms/v3/createdby/me")]
|
||||
[HttpGet("roomserver/rooms/createdby/me")]
|
||||
[HttpGet("roomserver/rooms/ownedby/me")]
|
||||
[HttpGet("rooms/createdby/me")]
|
||||
[HttpGet("rooms/ownedby/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MyRooms()
|
||||
{
|
||||
@@ -1462,46 +929,7 @@ public class RoomsController(
|
||||
return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v1/createdby/{otherPlayerId:long}")]
|
||||
[HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")]
|
||||
[HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")]
|
||||
[HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")]
|
||||
[HttpGet("rooms/createdby/{otherPlayerId:long}")]
|
||||
public async Task<IActionResult> CreatedByOther(long otherPlayerId)
|
||||
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
[HttpGet("api/rooms/v1/visitedby/me")]
|
||||
[HttpGet("api/rooms/v2/visitedby/me")]
|
||||
[HttpGet("api/rooms/v3/visitedby/me")]
|
||||
[HttpGet("roomserver/rooms/visitedby/me")]
|
||||
[HttpGet("rooms/visitedby/me")]
|
||||
[Authorize]
|
||||
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
|
||||
|
||||
[HttpGet("api/rooms/v1/moderatedby/me")]
|
||||
[HttpGet("api/rooms/v2/moderatedby/me")]
|
||||
[HttpGet("api/rooms/v3/moderatedby/me")]
|
||||
[HttpGet("roomserver/rooms/moderatedby/me")]
|
||||
[HttpGet("rooms/moderatedby/me")]
|
||||
[Authorize]
|
||||
public IActionResult ModeratedByMe() => Ok(Array.Empty<object>());
|
||||
|
||||
[HttpGet("api/rooms/v1/favoritedby/me")]
|
||||
[HttpGet("api/rooms/v2/favoritedby/me")]
|
||||
[HttpGet("api/rooms/v3/favoritedby/me")]
|
||||
[HttpGet("roomserver/rooms/favoritedby/me")]
|
||||
[HttpGet("rooms/favoritedby/me")]
|
||||
[Authorize]
|
||||
public IActionResult FavoritedByMe() => Ok(Array.Empty<object>());
|
||||
|
||||
[HttpGet("api/rooms/v1/cheeredby/me")]
|
||||
[HttpGet("api/rooms/v2/cheeredby/me")]
|
||||
[HttpGet("api/rooms/v3/cheeredby/me")]
|
||||
[HttpGet("roomserver/rooms/cheeredby/me")]
|
||||
[HttpGet("rooms/cheeredby/me")]
|
||||
[Authorize]
|
||||
public IActionResult CheeredByMe() => Ok(Array.Empty<object>());
|
||||
|
||||
[HttpGet("api/rooms/v1/mybookmarkedrooms")]
|
||||
[HttpGet("api/rooms/v2/mybookmarks")]
|
||||
[HttpGet("api/rooms/v2/mybookmarkedrooms")]
|
||||
[Authorize]
|
||||
@@ -1515,6 +943,7 @@ public class RoomsController(
|
||||
[HttpGet("api/rooms/v2/mymoderated")]
|
||||
[HttpGet("api/rooms/v1/modrooms")]
|
||||
[HttpGet("api/rooms/v2/mysubscribed")]
|
||||
[HttpGet("api/rooms/v1/myrecent")]
|
||||
[HttpGet("api/rooms/v2/myrecent")]
|
||||
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
|
||||
|
||||
@@ -1548,7 +977,6 @@ public class RoomsController(
|
||||
/// seeded AG / RR-Original room id. Used by the watch to bias the
|
||||
/// room browser toward official rooms first.</summary>
|
||||
[HttpGet("api/rooms/v1/agRoomIds")]
|
||||
[HttpGet("rooms/rro_ids")]
|
||||
public async Task<IActionResult> AgRoomIds() =>
|
||||
Ok(await rooms.AgRoomIdsAsync());
|
||||
|
||||
@@ -1563,27 +991,24 @@ public class RoomsController(
|
||||
FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12),
|
||||
});
|
||||
|
||||
/// <summary>GET <c>featuredrooms/current</c> — the watch's pointer
|
||||
/// to the current "Featured" room group. Caller:
|
||||
/// <c>EJDCNGBEICB.CFKDADKHAGB</c>. Wire shape is <c>NMPFCIJPODA</c>
|
||||
/// with required keys <c>FeaturedRoomGroupId</c> (long) and
|
||||
/// <c>Name</c> (string) — a pointer record, NOT the room list
|
||||
/// itself (the watch fetches the list separately via
|
||||
/// <c>api/rooms/v3/featured</c>). Returning the
|
||||
/// <see cref="Featured"/> shape here throws KeyNotFoundException on
|
||||
/// the watch's group-id lookup.</summary>
|
||||
[HttpGet("featuredrooms/current")]
|
||||
public async Task<IActionResult> FeaturedRoomsCurrent() => Ok(new
|
||||
/// <summary>GET <c>api/rooms/v2/featured</c> — the 2018 client expects a
|
||||
/// SINGLE Room object here (RecNet.cs:85173), not the 2020 FeaturedRoomGroup.
|
||||
/// Return the top featured AG room.</summary>
|
||||
[HttpGet("api/rooms/v2/featured")]
|
||||
public async Task<IActionResult> FeaturedSingle()
|
||||
{
|
||||
FeaturedRoomGroupId = 1L,
|
||||
Name = "Featured",
|
||||
// NMPFCIJPODA.PPGFHEDFBEA (NMPFCIJPODA.txt:100-125) reads
|
||||
// 3 strict keys, not 2 — FeaturedRoomGroupId, Name, AND
|
||||
// Rooms (List<PPKJFAAAGDO>). Returning without the third
|
||||
// key throws KeyNotFoundException on the watch and the
|
||||
// Featured carousel stays empty.
|
||||
Rooms = await rooms.FeaturedAgRoomIdsAsync(12),
|
||||
});
|
||||
var ids = await rooms.FeaturedAgRoomIdsAsync(1);
|
||||
var r = ids.Count > 0 ? await rooms.GetByIdAsync(ids[0]) : null;
|
||||
return Ok(RoomService.ToWireRoom(r ?? Synthetic("RecCenter")));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/rooms/v2/details/{roomId}</c> — the 2018 details call
|
||||
/// returns ONLY live counts {GameSessionCount, PlayerCount} (RecNet
|
||||
/// PFMOEEDCNBK, RecNet.cs:79846), distinct from the 2020 v3/v4 RoomDetails.
|
||||
/// Counts are derived from current presence; 0/0 when the room is empty.</summary>
|
||||
[HttpGet("api/rooms/v2/details/{roomId:long}")]
|
||||
public IActionResult DetailsV2(long roomId)
|
||||
=> Ok(new { GameSessionCount = 0, PlayerCount = 0 });
|
||||
|
||||
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
|
||||
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
|
||||
@@ -1601,148 +1026,6 @@ public class RoomsController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{roomId}/name</c> — rename a room.
|
||||
/// Watch path: <c>EJDCNGBEICB.DNOGGKKHMHI(long roomId, string newName)</c>
|
||||
/// (analytics tag "change room name"). Body shape: form-urlencoded
|
||||
/// <c>Name=...</c> or a bare string in the body. Returns the
|
||||
/// updated Room DTO (<c>PPENFJMFPNE : KLCOGEIGEBJ</c>).
|
||||
///
|
||||
/// <para>Caller must be the room creator OR a co-owner. Other
|
||||
/// validations: name length 1..40, no leading/trailing whitespace,
|
||||
/// uniqueness against other Rooms.Name. Failures return
|
||||
/// the watch's <c>CreateModifyRoomStatus</c>-style payload with
|
||||
/// a non-zero Result so the rename toast surfaces a real reason.</para>
|
||||
///
|
||||
/// <para>We accept both POST and PUT because the watch's ISIL
|
||||
/// doesn't expose the explicit HTTPMethod literal at this call
|
||||
/// site — covering both is cheap and means we can't get bitten
|
||||
/// by the wrong one.</para></summary>
|
||||
public sealed class RenameBody { public string? Name { get; set; } }
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/name")]
|
||||
[HttpPut("rooms/{roomId:long}/name")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RenameRoom(
|
||||
long roomId,
|
||||
[FromForm(Name = "Name")] string? nameForm,
|
||||
[FromForm(Name = "name")] string? nameFormLower,
|
||||
[FromBody] RenameBody? body)
|
||||
{
|
||||
var newName = nameForm ?? nameFormLower ?? body?.Name;
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
return BadRequest(new { Result = 1, error = "empty_name" });
|
||||
newName = newName.Trim();
|
||||
if (newName.Length is < 1 or > 40)
|
||||
return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length });
|
||||
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
|
||||
if (room.CreatorPlayerId != me)
|
||||
{
|
||||
// RoomRoles co-owner check — same authorisation as the
|
||||
// existing modify endpoint, lets co-owners rename too.
|
||||
var coOwner = await db.RoomRoles.AnyAsync(rr =>
|
||||
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
|
||||
if (!coOwner) return Forbid();
|
||||
}
|
||||
|
||||
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
|
||||
var collision = await db.Rooms.AnyAsync(r => r.Id != roomId && r.Name == newName);
|
||||
if (collision) return Conflict(new { Result = 5, error = "name_taken" });
|
||||
|
||||
room.Name = newName;
|
||||
room.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName);
|
||||
|
||||
// Return the full updated Room DTO. The watch's
|
||||
// <c>PPENFJMFPNE</c> deserialiser inherits from
|
||||
// <c>KLCOGEIGEBJ</c> (the standard Room shape) so we can
|
||||
// forward to the same v3-details payload builder by re-reading.
|
||||
// BuildRoomDetailsV3Async is too heavy here — just return the
|
||||
// minimal mutated subset; the watch UI re-fetches details
|
||||
// shortly after for the room-info screen.
|
||||
return Ok(new
|
||||
{
|
||||
Result = 0,
|
||||
RoomId = room.Id,
|
||||
Name = room.Name,
|
||||
Description = room.Description,
|
||||
ImageName = room.ImageName,
|
||||
CreatorPlayerId = room.CreatorPlayerId,
|
||||
CreatorAccountId = room.CreatorPlayerId,
|
||||
State = room.State,
|
||||
Accessibility = room.Accessibility,
|
||||
IsAGRoom = room.IsAGRoom,
|
||||
IsRRO = false,
|
||||
IsDormRoom = room.IsDormRoom,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{roomId}/subrooms/{subRoomId}/name</c> —
|
||||
/// rename a scene/sub-room within a multi-scene room. Watch path:
|
||||
/// <c>EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName)</c>.
|
||||
/// Updates the matching <see cref="RoomSceneEntity.Name"/> after
|
||||
/// verifying ownership and intra-room uniqueness.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RenameSubroom(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[FromForm(Name = "Name")] string? nameForm,
|
||||
[FromForm(Name = "name")] string? nameFormLower,
|
||||
[FromBody] RenameBody? body)
|
||||
{
|
||||
var newName = nameForm ?? nameFormLower ?? body?.Name;
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
return BadRequest(new { Result = 1, error = "empty_name" });
|
||||
newName = newName.Trim();
|
||||
if (newName.Length is < 1 or > 40)
|
||||
return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length });
|
||||
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
|
||||
if (room.CreatorPlayerId != me)
|
||||
{
|
||||
var coOwner = await db.RoomRoles.AnyAsync(rr =>
|
||||
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
|
||||
if (!coOwner) return Forbid();
|
||||
}
|
||||
|
||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
|
||||
s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||
if (scene is null) return NotFound(new { Result = 4, error = "subroom_not_found", subRoomId });
|
||||
|
||||
// Uniqueness within this room — same scene name twice would
|
||||
// make /goto/room/X/Y ambiguous against the RoomScenes index
|
||||
// on (RoomId, Name) at DorkNetDbContext.cs:153.
|
||||
var collision = await db.RoomScenes.AnyAsync(s =>
|
||||
s.RoomId == roomId && s.OrderIndex != subRoomId && s.Name == newName);
|
||||
if (collision) return Conflict(new { Result = 5, error = "subroom_name_taken" });
|
||||
|
||||
scene.Name = newName;
|
||||
scene.DataModifiedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Result = 0,
|
||||
RoomId = roomId,
|
||||
RoomSceneId = scene.OrderIndex,
|
||||
SubRoomId = scene.OrderIndex,
|
||||
Name = scene.Name,
|
||||
scene.DataBlobName,
|
||||
RoomSceneLocationId = scene.RoomSceneLocationId,
|
||||
scene.IsSandbox,
|
||||
scene.MaxPlayers,
|
||||
scene.CanMatchmakeInto,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
|
||||
/// form). Idempotent per (caller, room) — duplicate taps don't
|
||||
/// inflate CheerCount.</summary>
|
||||
@@ -1786,965 +1069,6 @@ public class RoomsController(
|
||||
return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered });
|
||||
}
|
||||
|
||||
// ── Bare-path interactionby/me toggles ─────────────────────────────
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/interactionby/me/cheer")]
|
||||
[HttpPut("rooms/{roomId:long}/interactionby/me/cheer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BareCheer(long roomId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
await rooms.CheerRoomAsync(me, roomId, 0);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BareUncheer(long roomId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
await rooms.UncheerRoomAsync(me, roomId, 0);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/interactionby/me/favorite")]
|
||||
[HttpPut("rooms/{roomId:long}/interactionby/me/favorite")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BareFavorite(long roomId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
await rooms.BookmarkAsync(me, roomId);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BareUnfavorite(long roomId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
await rooms.UnbookmarkAsync(me, roomId);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// ── Rooms-and-Playlists union (2020.12 watch) ────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/roomserver/roomsandplaylists/hot</c> — the 2020.12 watch's
|
||||
/// merged "Trending" / "New" tab. Two URL variants exist with
|
||||
/// DIFFERENT wire shapes:
|
||||
///
|
||||
/// 1. <c>/roomserver/roomsandplaylists/hot</c> (this endpoint)
|
||||
/// Watch caller: <c>EJDCNGBEICB.EBBHGHEGBKG</c>
|
||||
/// Signature: <c>IPromise<HJKAOMOICJG></c>
|
||||
/// Wire shape: <c>{ TotalResults, Results: [union] }</c>
|
||||
/// (verified at <c>EJDCNGBEICB.txt:2571,2645</c> +
|
||||
/// <c>HJKAOMOICJG.txt:39-50</c>)
|
||||
/// 2. <c>/roomserver/hot_roomsandplaylists/{tags-joined}</c>
|
||||
/// Watch caller: <c>OJMCBOKJFOF.EBBHGHEGBKG</c>
|
||||
/// Signature: <c>IPromise<List<MKAMHOIHOJK>></c>
|
||||
/// Wire shape: bare list, see <see cref="HotRoomsAndPlaylistsPath"/>
|
||||
/// (verified at <c>OJMCBOKJFOF.txt:4607,4743</c>)
|
||||
///
|
||||
/// Returning a bare list here throws
|
||||
/// <c>InvalidCastException: Unable to cast object of type 'List`1'
|
||||
/// to type 'Dictionary`2'</c> on the watch — the wrapped
|
||||
/// HJKAOMOICJG deserializer reads <c>TotalResults</c> as a top-level
|
||||
/// dict key, and a JSON array body fails that cast.
|
||||
///
|
||||
/// Inside <c>Results</c>: union entries discriminated by lowercase
|
||||
/// <c>roomId</c> key per the <c>MKAMHOIHOJK</c> factory at
|
||||
/// <c>MKAMHOIHOJK.txt:621</c> — see <see cref="BuildRoomUnionEntry"/>
|
||||
/// + <see cref="BuildPlaylistUnionEntry"/>.
|
||||
///
|
||||
/// <para><b>Why rooms-only despite the "and playlists" name:</b> the
|
||||
/// Hot tab of the watch's Play page mounts this endpoint and used to
|
||||
/// interleave playlist tiles next to room tiles, but design intent is
|
||||
/// that playlists appear only under the Moods/Playlists section (their
|
||||
/// own endpoints — <c>/api/curatedroomplaylists</c> +
|
||||
/// <c>/roomserver/playlists/{id}</c>). The watch's union deserializer
|
||||
/// happily accepts a Results list that's 100% rooms, so we keep the
|
||||
/// wrapper shape but drop the playlist half.</para>
|
||||
/// </summary>
|
||||
[HttpGet("roomserver/roomsandplaylists/hot")]
|
||||
[HttpGet("roomsandplaylists/hot")]
|
||||
public async Task<IActionResult> RoomsAndPlaylistsHot([FromQuery] string? tag)
|
||||
{
|
||||
var (entries, total) = await BuildHotRoomsAsync(tag);
|
||||
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["TotalResults"] = (long)total,
|
||||
["Results"] = entries,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/roomserver/hot_roomsandplaylists/{tagsJoined}</c> —
|
||||
/// path-style sibling of <see cref="RoomsAndPlaylistsHot"/> with a
|
||||
/// BARE <c>List<MKAMHOIHOJK></c> shape (no TotalResults
|
||||
/// wrapper). Tags are comma-joined into the URL path per
|
||||
/// <c>OJMCBOKJFOF.txt:4739,4743</c> (<c>String.Join(",", tags)</c>
|
||||
/// followed by <c>String.Concat("hot_roomsandplaylists/", joined)</c>).
|
||||
/// Empty path is legal — equivalent to "no tag filter".
|
||||
/// </summary>
|
||||
[HttpGet("roomserver/hot_roomsandplaylists/{tagsJoined?}")]
|
||||
[HttpGet("hot_roomsandplaylists/{tagsJoined?}")]
|
||||
public async Task<IActionResult> HotRoomsAndPlaylistsPath(string? tagsJoined)
|
||||
{
|
||||
// Watch joins multiple tags with "," — use the first one as a
|
||||
// substring filter since RoomService.HotAsync takes a single
|
||||
// tag arg. Empty / null = unfiltered.
|
||||
//
|
||||
// Returns rooms-only for the same reason as the wrapped
|
||||
// RoomsAndPlaylistsHot endpoint above: playlists belong in
|
||||
// Moods, not in the Hot tab. See that endpoint's doc-comment.
|
||||
var firstTag = string.IsNullOrWhiteSpace(tagsJoined)
|
||||
? null
|
||||
: tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.FirstOrDefault();
|
||||
var (entries, _) = await BuildHotRoomsAsync(firstTag);
|
||||
return Ok(entries);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>hot_rooms/{tagsJoined?}</c> — rooms-only variant
|
||||
/// of <see cref="HotRoomsAndPlaylistsPath"/>. Caller:
|
||||
/// <c>OJMCBOKJFOF.DLPLPKCNLNA</c>. Same <c>MKAMHOIHOJK</c> union
|
||||
/// entry shape as <c>hot_roomsandplaylists</c>, just skips playlist
|
||||
/// rows so the watch's rooms-only browse tab doesn't render
|
||||
/// playlist tiles that have nowhere to go.</summary>
|
||||
[HttpGet("hot_rooms/{tagsJoined?}")]
|
||||
public async Task<IActionResult> HotRoomsOnlyPath(string? tagsJoined)
|
||||
{
|
||||
var firstTag = string.IsNullOrWhiteSpace(tagsJoined)
|
||||
? null
|
||||
: tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.FirstOrDefault();
|
||||
var roomList = await rooms.HotAsync(firstTag, take: 12);
|
||||
var roomIds = roomList.Select(r => r.Id).ToList();
|
||||
var sceneRows = roomIds.Count == 0
|
||||
? new List<RoomSceneEntity>()
|
||||
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId))
|
||||
.OrderBy(s => s.OrderIndex).ToListAsync();
|
||||
var scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
|
||||
return Ok(roomList
|
||||
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared body for the two hot variants — pulls hot rooms only,
|
||||
/// fans out sub-room rows in one query to avoid N+1, and returns
|
||||
/// the union-shape entries the watch's factory expects (each entry
|
||||
/// happens to be a room dict; the factory accepts that). Returns
|
||||
/// (entries, totalCount) so the wrapped variant can fill in
|
||||
/// TotalResults.
|
||||
///
|
||||
/// <para>Take limit raised to 100 (was 12) — the Hot tab used to
|
||||
/// surface only the top dozen rooms which is far less than the
|
||||
/// catalog the watch's browse UI can actually render. The watch
|
||||
/// paginates client-side if it needs to chunk further.</para>
|
||||
/// </summary>
|
||||
private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildHotRoomsAsync(string? tag)
|
||||
{
|
||||
var roomList = await rooms.HotAsync(tag, take: 100);
|
||||
|
||||
var roomIds = roomList.Select(r => r.Id).ToList();
|
||||
var sceneRows = roomIds.Count == 0
|
||||
? new List<RoomSceneEntity>()
|
||||
: await db.RoomScenes
|
||||
.Where(s => roomIds.Contains(s.RoomId))
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var scenesByRoom = sceneRows
|
||||
.GroupBy(s => s.RoomId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
|
||||
|
||||
var entries = roomList
|
||||
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
|
||||
.ToList();
|
||||
return (entries, entries.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/api/curatedroomplaylists</c> — the editorial "Curated"
|
||||
/// row on the watch's playlists tab.
|
||||
///
|
||||
/// Wire shape: <c>List<Int64></c> — JUST the playlist IDs, not
|
||||
/// the full playlist objects. Verified against
|
||||
/// <c>FPNPBJBCMKB.EPPPMHMMGME</c> (signature
|
||||
/// <c>IPromise<List<long>></c>) and the call site at
|
||||
/// <c>BrowseRoomsScreen.txt:2806</c> which constructs an
|
||||
/// <c>Action<List<Int64>></c> callback. Returning rich
|
||||
/// objects here throws
|
||||
/// <c>InvalidCastException: Unable to cast object of type 'List`1'
|
||||
/// to type 'Dictionary`2'</c> on the watch ("Failed to get curated
|
||||
/// room playlists: Malformed Response"). The watch fetches each
|
||||
/// playlist's details separately via the playlist-detail endpoints.
|
||||
/// </summary>
|
||||
[HttpGet("api/curatedroomplaylists")]
|
||||
public async Task<IActionResult> CuratedRoomPlaylists()
|
||||
{
|
||||
var curated = await playlists.CuratedAsync();
|
||||
return Ok(curated.Select(p => p.Id).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/roomserver/playlists/{id}</c> — single-playlist details
|
||||
/// with members. The watch deserializes this as <b>BMFAGMFKODA</b>
|
||||
/// (verified at <c>BMFAGMFKODA.txt:182-191</c>) which:
|
||||
/// 1. Calls <c>KMKPEOGJDFK.PPGFHEDFBEA</c> to read the base
|
||||
/// Playlist union fields (<c>PlaylistId</c> + all
|
||||
/// <c>MKAMHOIHOJK</c> base keys).
|
||||
/// 2. Reads required key <c>Rooms</c> — <c>List<KLCOGEIGEBJ></c>
|
||||
/// (the playlist's member rooms, full Room dicts).
|
||||
/// 3. Reads required key <c>Tags</c> — <c>List<DPHPFLGAICI></c>
|
||||
/// (tag rows with <c>Type</c> + <c>Tag</c>).
|
||||
/// Returning just the <c>KMKPEOGJDFK</c> shape (no Rooms/Tags) throws
|
||||
/// <c>KeyNotFoundException: Failed to find key 'Rooms' when
|
||||
/// deserializing object of type KLCOGEIGEBJ</c> on the watch.
|
||||
/// </summary>
|
||||
[HttpGet("roomserver/playlists/{id:long}")]
|
||||
[HttpGet("playlists/{id:long}")]
|
||||
public async Task<IActionResult> PlaylistDetails(long id)
|
||||
{
|
||||
var p = await db.Playlists.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (p is null) return NotFound();
|
||||
|
||||
// Pull member rooms in playlist order, then fan out room +
|
||||
// scene rows so each entry can render as a full KLCOGEIGEBJ.
|
||||
var roomIds = await db.PlaylistRooms
|
||||
.Where(pr => pr.PlaylistId == id)
|
||||
.OrderBy(pr => pr.OrderIndex)
|
||||
.Select(pr => pr.RoomId)
|
||||
.ToListAsync();
|
||||
|
||||
List<RoomEntity> memberRooms;
|
||||
Dictionary<long, IReadOnlyList<RoomSceneEntity>> scenesByRoom;
|
||||
if (roomIds.Count == 0)
|
||||
{
|
||||
memberRooms = new List<RoomEntity>();
|
||||
scenesByRoom = new Dictionary<long, IReadOnlyList<RoomSceneEntity>>();
|
||||
}
|
||||
else
|
||||
{
|
||||
var roomRows = await db.Rooms
|
||||
.Where(r => roomIds.Contains(r.Id))
|
||||
.ToListAsync();
|
||||
// Preserve playlist order — db.Rooms.Where doesn't guarantee
|
||||
// it matches the OrderBy on PlaylistRooms.OrderIndex.
|
||||
var roomById = roomRows.ToDictionary(r => r.Id);
|
||||
memberRooms = roomIds
|
||||
.Select(rid => roomById.TryGetValue(rid, out var r) ? r : null)
|
||||
.Where(r => r is not null)
|
||||
.Select(r => r!)
|
||||
.ToList();
|
||||
|
||||
var sceneRows = await db.RoomScenes
|
||||
.Where(s => roomIds.Contains(s.RoomId))
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
scenesByRoom = sceneRows
|
||||
.GroupBy(s => s.RoomId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
|
||||
}
|
||||
|
||||
var roomsWire = memberRooms
|
||||
.Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
|
||||
.ToList();
|
||||
|
||||
// Tags wire shape: [{Type:int, Tag:string}] — same shape used in
|
||||
// BuildRoomServerDetails for Room.Tags. Split TagsCsv if present.
|
||||
var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv)
|
||||
? new List<object>()
|
||||
: p.TagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => (object)new { Type = 0, Tag = t })
|
||||
.ToList();
|
||||
|
||||
// Start from BuildPlaylistUnionEntry for base + PlaylistId, then
|
||||
// splice in the Rooms + Tags lists. Dictionary lets us add the
|
||||
// extra keys after the base build.
|
||||
var result = BuildPlaylistUnionEntry(p);
|
||||
result["Rooms"] = roomsWire;
|
||||
result["Tags"] = tagsWire;
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/roomserver/roomsandplaylists/search?query=X</c> — search
|
||||
/// surface used by the watch's friend-mention / room-search affordance.
|
||||
/// Verified at <c>EJDCNGBEICB.txt:2486,2560</c> — signature
|
||||
/// <c>IPromise<HJKAOMOICJG></c>. Per
|
||||
/// <see cref="HJKAOMOICJG.PPGFHEDFBEA"/> at <c>HJKAOMOICJG.txt:39-50</c>
|
||||
/// the response object reads <c>TotalResults</c> (Int64) and
|
||||
/// <c>Results</c> (List of <c>MKAMHOIHOJK</c> union entries — same
|
||||
/// Room/Playlist factory used by <c>/hot</c>).
|
||||
///
|
||||
/// Distinct from <c>/roomserver/search_roomsandplaylists/{query}</c>
|
||||
/// (path-param shape from <c>OJMCBOKJFOF.txt:4567</c>); both URLs
|
||||
/// reach the same data but differ in shape — the path-param sibling
|
||||
/// returns a bare <c>List<MKAMHOIHOJK></c> without the
|
||||
/// TotalResults wrapper.
|
||||
/// </summary>
|
||||
[HttpGet("roomserver/roomsandplaylists/search")]
|
||||
[HttpGet("roomsandplaylists/search")]
|
||||
public async Task<IActionResult> RoomsAndPlaylistsSearch([FromQuery] string? query)
|
||||
{
|
||||
var (merged, total) = await BuildSearchUnionAsync(query);
|
||||
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["TotalResults"] = (long)total,
|
||||
["Results"] = merged,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/roomserver/search_roomsandplaylists/{query}</c> — free-text
|
||||
/// search across both rooms and playlists, BARE-LIST variant. Per
|
||||
/// <c>OJMCBOKJFOF.txt:4439,4567</c> the signature is
|
||||
/// <c>IPromise<List<MKAMHOIHOJK>></c> — no TotalResults
|
||||
/// wrapper. URL is <c>search_roomsandplaylists/{query}</c>
|
||||
/// (query in path, not query-string).
|
||||
/// </summary>
|
||||
[HttpGet("roomserver/search_roomsandplaylists/{query}")]
|
||||
[HttpGet("search_roomsandplaylists/{query}")]
|
||||
public async Task<IActionResult> SearchRoomsAndPlaylists(string query)
|
||||
{
|
||||
var (merged, _) = await BuildSearchUnionAsync(query);
|
||||
return Ok(merged);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>search_rooms/{query}</c> — rooms-only search
|
||||
/// variant. Same <c>MKAMHOIHOJK</c> union entry shape as
|
||||
/// <c>search_roomsandplaylists/{query}</c>, just skips playlist
|
||||
/// rows.</summary>
|
||||
[HttpGet("search_rooms/{query}")]
|
||||
public async Task<IActionResult> SearchRoomsOnly(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return Ok(Array.Empty<object>());
|
||||
var roomList = await rooms.SearchAsync(query);
|
||||
var roomIds = roomList.Select(r => r.Id).ToList();
|
||||
var sceneRows = roomIds.Count == 0
|
||||
? new List<RoomSceneEntity>()
|
||||
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId))
|
||||
.OrderBy(s => s.OrderIndex).ToListAsync();
|
||||
var scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
|
||||
return Ok(roomList
|
||||
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared body for the two search variants. Empty/whitespace query
|
||||
/// returns an empty result without round-tripping the DB.
|
||||
/// </summary>
|
||||
private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildSearchUnionAsync(string? query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return (new List<IDictionary<string, object>>(), 0);
|
||||
|
||||
// Sequential — shared scoped DbContext isn't thread-safe.
|
||||
var roomList = await rooms.SearchAsync(query);
|
||||
var playlistList = await playlists.SearchAsync(query);
|
||||
|
||||
var roomIds = roomList.Select(r => r.Id).ToList();
|
||||
var sceneRows = await db.RoomScenes
|
||||
.Where(s => roomIds.Contains(s.RoomId))
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var scenesByRoom = sceneRows
|
||||
.GroupBy(s => s.RoomId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
|
||||
|
||||
var merged = InterleaveUnion(
|
||||
roomList.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))),
|
||||
playlistList.Select(BuildPlaylistUnionEntry));
|
||||
return (merged, roomList.Count + playlistList.Count);
|
||||
}
|
||||
|
||||
/// <summary>Round-robin merge of two enumerables so the watch's
|
||||
/// hot row alternates between rooms and playlists rather than
|
||||
/// stacking one type on top. Once one side runs out the remainder
|
||||
/// of the other side spills out in order.</summary>
|
||||
private static List<IDictionary<string, object>> InterleaveUnion(
|
||||
IEnumerable<IDictionary<string, object>> a,
|
||||
IEnumerable<IDictionary<string, object>> b)
|
||||
{
|
||||
var listA = a.ToList();
|
||||
var listB = b.ToList();
|
||||
var output = new List<IDictionary<string, object>>(listA.Count + listB.Count);
|
||||
int i = 0, j = 0;
|
||||
while (i < listA.Count || j < listB.Count)
|
||||
{
|
||||
if (i < listA.Count) output.Add(listA[i++]);
|
||||
if (j < listB.Count) output.Add(listB[j++]);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a union-shape Room entry — same payload as
|
||||
/// <see cref="BuildRoomServerDetails"/> but with the additional
|
||||
/// lowercase <c>roomId</c> key that the 2020.12
|
||||
/// <c>MKAMHOIHOJK</c> factory uses as its Room-vs-Playlist
|
||||
/// discriminator (factory at <c>MKAMHOIHOJK.txt:621</c> calls
|
||||
/// <c>Dictionary.ContainsKey("roomId")</c>; the actual Room
|
||||
/// deserializer at <c>KLCOGEIGEBJ.txt:208-234</c> reads
|
||||
/// PascalCase <c>RoomId</c>). Must include BOTH keys.
|
||||
/// </summary>
|
||||
private static IDictionary<string, object> BuildRoomUnionEntry(
|
||||
RoomEntity room,
|
||||
IReadOnlyList<RoomSceneEntity>? sceneRows)
|
||||
{
|
||||
// Start from the canonical RoomServerDetails shape so the union
|
||||
// entry carries every field the 2020.12 client expects on a
|
||||
// Room subclass (SubRooms, Roles, Stats, etc.). Then add the
|
||||
// lowercase 'roomId' discriminator key on top.
|
||||
var details = BuildRoomServerDetails(room, sceneRows);
|
||||
// Convert anonymous record into a mutable dictionary so we can
|
||||
// add the lowercase 'roomId' key that anonymous types can't
|
||||
// express (you can't have two properties differing only in
|
||||
// letter casing on a single anonymous type).
|
||||
var dict = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var prop in details.GetType().GetProperties())
|
||||
{
|
||||
var value = prop.GetValue(details);
|
||||
if (value is not null) dict[prop.Name] = value;
|
||||
}
|
||||
// Discriminator key — lowercase 'roomId'. Without this the
|
||||
// MKAMHOIHOJK factory falls through to the Playlist branch
|
||||
// and PlaylistId.PPGFHEDFBEA throws on the missing key.
|
||||
dict["roomId"] = room.Id;
|
||||
return dict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a union-shape Playlist entry — the
|
||||
/// <see cref="KMKPEOGJDFK"/> branch of MKAMHOIHOJK. Keys read at
|
||||
/// <c>MKAMHOIHOJK.txt:516-612</c> (PascalCase base keys) and
|
||||
/// <c>KMKPEOGJDFK.txt:68</c> (PascalCase <c>PlaylistId</c>).
|
||||
/// Omits the lowercase <c>roomId</c> key so the factory's
|
||||
/// Room-vs-Playlist branch falls through to the Playlist
|
||||
/// constructor.
|
||||
/// </summary>
|
||||
public static IDictionary<string, object> BuildPlaylistUnionEntry(PlaylistEntity p)
|
||||
{
|
||||
return new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["PlaylistId"] = p.Id,
|
||||
["Name"] = p.Name,
|
||||
["Description"] = p.Description,
|
||||
["ImageName"] = p.ImageName,
|
||||
["CreatorAccountId"] = p.CreatorPlayerId,
|
||||
["State"] = 0,
|
||||
["Accessibility"] = 1,
|
||||
["WarningMask"] = 0,
|
||||
["CustomWarning"] = string.Empty,
|
||||
["SupportsLevelVoting"] = false,
|
||||
// Playlists are watchable from every device the
|
||||
// member rooms support; advertise the broadest set so
|
||||
// the watch never filters them out by capability.
|
||||
["IsRRO"] = p.IsCurated,
|
||||
["SupportsScreens"] = true,
|
||||
["SupportsWalkVR"] = true,
|
||||
["SupportsTeleportVR"] = true,
|
||||
["SupportsVRLow"] = true,
|
||||
["SupportsQuest2"] = true,
|
||||
["SupportsMobile"] = true,
|
||||
["SupportsJuniors"] = true,
|
||||
["CreatedAt"] = (p.CreatedAt == default ? DateTime.UtcNow : p.CreatedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
["Stats"] = new
|
||||
{
|
||||
CheerCount = p.CheerCount,
|
||||
FavoriteCount = p.FavoriteCount,
|
||||
VisitorCount = p.VisitorCount,
|
||||
VisitCount = p.VisitCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Subroom mutations (bare path) ──────────────────────────────────
|
||||
|
||||
public sealed class CreateSubRoomRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? RoomSceneLocationId { get; set; }
|
||||
public int? MaxPlayers { get; set; }
|
||||
public bool? IsSandbox { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/subrooms</c> — append a new scene.
|
||||
/// Owner-gated. OrderIndex = max+1, Name unique within room.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
|
||||
var name = (req.Name ?? string.Empty).Trim();
|
||||
if (name.Length == 0) name = "NewScene";
|
||||
var collision = await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == name);
|
||||
if (collision) return Conflict(new { error = "name_taken" });
|
||||
|
||||
var nextOrder = (await db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId)
|
||||
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
|
||||
var scene = new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = nextOrder,
|
||||
Name = name,
|
||||
RoomSceneLocationId = req.RoomSceneLocationId
|
||||
?? "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||
MaxPlayers = req.MaxPlayers ?? 8,
|
||||
IsSandbox = req.IsSandbox ?? false,
|
||||
};
|
||||
db.RoomScenes.Add(scene);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SceneWire(scene));
|
||||
}
|
||||
|
||||
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
var scene = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" });
|
||||
db.RoomScenes.Remove(scene);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Result = 0 });
|
||||
}
|
||||
|
||||
public sealed class SubRoomIntRequest { public int? Value { get; set; } }
|
||||
public sealed class SubRoomBoolRequest { public bool? Value { get; set; } }
|
||||
public sealed class SubRoomModifyRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public int? MaxPlayers { get; set; }
|
||||
public bool? IsSandbox { get; set; }
|
||||
public bool? CanMatchmakeInto { get; set; }
|
||||
public string? RoomSceneLocationId { get; set; }
|
||||
}
|
||||
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); });
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/clone</c> — duplicate
|
||||
/// a scene (DataBlobName + location pointer). New scene goes at the
|
||||
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
var src = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||
if (src is null) return NotFound();
|
||||
|
||||
var baseName = $"{src.Name} Copy";
|
||||
var newName = baseName;
|
||||
int suffix = 1;
|
||||
while (await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == newName))
|
||||
{
|
||||
suffix += 1;
|
||||
newName = $"{baseName} {suffix}";
|
||||
}
|
||||
var nextOrder = (await db.RoomScenes.Where(s => s.RoomId == roomId)
|
||||
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
|
||||
var clone = new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = nextOrder,
|
||||
Name = newName,
|
||||
RoomSceneLocationId = src.RoomSceneLocationId,
|
||||
DataBlobName = src.DataBlobName,
|
||||
MaxPlayers = src.MaxPlayers,
|
||||
IsSandbox = src.IsSandbox,
|
||||
CanMatchmakeInto = src.CanMatchmakeInto,
|
||||
};
|
||||
db.RoomScenes.Add(clone);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SceneWire(clone));
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim();
|
||||
if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp);
|
||||
if (req.IsSandbox is bool sb) s.IsSandbox = sb;
|
||||
if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi;
|
||||
if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId;
|
||||
});
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c> — reorder a
|
||||
/// scene. Body NewIndex is the target OrderIndex; the in-between
|
||||
/// rows shift to fill the gap.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
var scenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync();
|
||||
var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId);
|
||||
if (moving is null) return NotFound();
|
||||
var newIndex = Math.Clamp(req.NewIndex ?? 0, 0, scenes.Count - 1);
|
||||
scenes.Remove(moving);
|
||||
scenes.Insert(newIndex, moving);
|
||||
// Recompute OrderIndex on the entire row set. The unique
|
||||
// (RoomId, OrderIndex) index would clash if we wrote rows in
|
||||
// place — flush a sentinel offset first, then settle to
|
||||
// final indices.
|
||||
const int sentinelOffset = 100_000;
|
||||
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset;
|
||||
await db.SaveChangesAsync();
|
||||
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SceneWire(moving));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>roomserver/rooms/{id}/subrooms/{sub}/data</c> —
|
||||
/// commit the blob returned by <c>storage/upload</c> as the current
|
||||
/// scene save. The 2020 watch posts this as form-urlencoded
|
||||
/// (<c>filename</c>, <c>inventionUsage</c>, <c>savedByAccountId</c>),
|
||||
/// while some tooling uses JSON. Both variants delegate to the
|
||||
/// existing saveData path so room, scene, dorm-state, presence, and
|
||||
/// subscription fanout stay consistent.</summary>
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
|
||||
{
|
||||
var body = await ReadSaveRoomSceneRequestAsync();
|
||||
body.RoomSceneId = subRoomId;
|
||||
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
|
||||
}
|
||||
|
||||
private async Task<SaveRoomSceneRequest> ReadSaveRoomSceneRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var request = new SaveRoomSceneRequest
|
||||
{
|
||||
Filename = FirstFormValue(form, "filename", "Filename"),
|
||||
RoomDataFilename = FirstFormValue(form, "roomDataFilename", "RoomDataFilename", "roomDataFileName", "RoomDataFileName"),
|
||||
InventionUsageBase64 = FirstFormValue(form, "inventionUsage", "InventionUsage", "inventionUsageBase64", "InventionUsageBase64"),
|
||||
};
|
||||
|
||||
if (long.TryParse(FirstFormValue(form, "requestPlayerId", "RequestPlayerId", "savedByAccountId", "SavedByAccountId"), out var requestPlayerId))
|
||||
request.RequestPlayerId = requestPlayerId;
|
||||
if (int.TryParse(FirstFormValue(form, "saveRequestPlayerId", "SaveRequestPlayerId"), out var saveRequestPlayerId))
|
||||
request.SaveRequestPlayerId = saveRequestPlayerId;
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<SaveRoomSceneRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new SaveRoomSceneRequest();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new SaveRoomSceneRequest();
|
||||
}
|
||||
}
|
||||
|
||||
return new SaveRoomSceneRequest();
|
||||
}
|
||||
|
||||
private static string? FirstFormValue(IFormCollection form, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var value = form[key].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
var scene = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
mutator(scene);
|
||||
scene.DataModifiedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SceneWire(scene));
|
||||
}
|
||||
|
||||
private static object SceneWire(RoomSceneEntity s) => new
|
||||
{
|
||||
Result = 0,
|
||||
RoomId = s.RoomId,
|
||||
RoomSceneId = s.OrderIndex,
|
||||
SubRoomId = s.OrderIndex,
|
||||
Name = s.Name,
|
||||
s.DataBlobName,
|
||||
RoomSceneLocationId = s.RoomSceneLocationId,
|
||||
s.IsSandbox,
|
||||
s.MaxPlayers,
|
||||
s.CanMatchmakeInto,
|
||||
};
|
||||
|
||||
// ── Bare-path roles ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>rooms/{id}/roles</c> — accepted role grants on
|
||||
/// the room. Owner is implicit (CreatorPlayerId) and surfaces under
|
||||
/// Role=30; co-owner/mod/host rows come from RoomRoles.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/roles")]
|
||||
public async Task<IActionResult> RolesList(long roomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||
var list = new List<object>(rows.Count + 1)
|
||||
{
|
||||
new { RoomId = roomId, PlayerId = (int)room.CreatorPlayerId, Role = 30 /*Owner*/, Accepted = true },
|
||||
};
|
||||
list.AddRange(rows.Select(r => (object)new
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = (int)r.PlayerId,
|
||||
Role = r.Role,
|
||||
Accepted = r.Accepted,
|
||||
}));
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId == playerId)
|
||||
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = 30, Accepted = true });
|
||||
var row = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
|
||||
if (row is null) return NotFound();
|
||||
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = row.Role, Accepted = row.Accepted });
|
||||
}
|
||||
|
||||
public sealed class GrantRoleRequest { public int? Role { get; set; } }
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/roles/{playerId}</c> — owner grants
|
||||
/// a role to a player. Auto-accepted (no invite step).</summary>
|
||||
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GrantRole(long roomId, long playerId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
|
||||
var role = await ReadClientRoomRoleAsync();
|
||||
if (role is null) return BadRequest(new { error = "missing_role" });
|
||||
if (role.Value == 0)
|
||||
return await ClearRoomAccountRoleAsync(room, playerId);
|
||||
|
||||
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
|
||||
return BadRequest(new { error = "invalid_role", role });
|
||||
|
||||
var existing = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
|
||||
if (existing is null)
|
||||
{
|
||||
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = playerId, Role = roomRole,
|
||||
Accepted = true, GrantedByPlayerId = me,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Accepted = true;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
|
||||
/// as grant but Accepted=false; the target's accept-invite flow
|
||||
/// flips the flag (separate endpoint, not yet exposed). For now
|
||||
/// invited rows surface in RoomDetails.InvitedCoOwners etc.</summary>
|
||||
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> InviteRole(long roomId, long playerId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
|
||||
var role = await ReadClientRoomRoleAsync();
|
||||
if (role is null) return BadRequest(new { error = "missing_role" });
|
||||
if (role.Value == 0)
|
||||
return await ClearRoomAccountRoleAsync(room, playerId);
|
||||
|
||||
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
|
||||
return BadRequest(new { error = "invalid_role", role });
|
||||
|
||||
var existing = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
|
||||
if (existing is null)
|
||||
{
|
||||
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = playerId, Role = roomRole,
|
||||
Accepted = false, GrantedByPlayerId = me,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Accepted = false;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId)
|
||||
return BadRequest(new { error = "cannot_change_creator_role" });
|
||||
|
||||
var rows = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
|
||||
.ToListAsync();
|
||||
if (rows.Count > 0)
|
||||
db.RoomRoles.RemoveRange(rows);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return true;
|
||||
return await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == room.Id
|
||||
&& r.PlayerId == playerId
|
||||
&& r.Accepted
|
||||
&& r.Role == 0);
|
||||
}
|
||||
|
||||
private async Task<int?> ReadClientRoomRoleAsync()
|
||||
{
|
||||
int parsed;
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
|
||||
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
|
||||
}
|
||||
|
||||
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
|
||||
if (int.TryParse(Request.Query["Role"].FirstOrDefault(), out parsed)) return parsed;
|
||||
|
||||
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (body?.Role is int bodyRole) return bodyRole;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
|
||||
{
|
||||
var rows = await db.RoomRoles
|
||||
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
|
||||
.ToListAsync();
|
||||
if (rows.Count > 0)
|
||||
db.RoomRoles.RemoveRange(rows);
|
||||
}
|
||||
|
||||
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
|
||||
{
|
||||
roomRole = clientRole switch
|
||||
{
|
||||
30 => 0, // CoOwner
|
||||
20 => 1, // Moderator
|
||||
10 => 2, // Host
|
||||
_ => -1,
|
||||
};
|
||||
return roomRole >= 0;
|
||||
}
|
||||
|
||||
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstAsync(r => r.Id == roomId);
|
||||
var scenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var roles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == roomId)
|
||||
.ToListAsync();
|
||||
return BuildRoomServerDetails(room, scenes, roles: roles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesise a placeholder Room when the client asks for one we don't
|
||||
/// have in the DB. Keeps the deserializer happy — every required key
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
@@ -23,20 +22,13 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2;
|
||||
[Authorize]
|
||||
public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private const int PublicAccessibility = 1;
|
||||
private const long CanUseShareCamPermission = 1L << 18;
|
||||
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
private async Task<RoomEntity?> RequireOwnedRoomAsync(long roomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return null;
|
||||
if (room.CreatorPlayerId == Me) return room;
|
||||
|
||||
var coOwner = await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == Me && r.Accepted && r.Role == 0);
|
||||
return coOwner ? room : null;
|
||||
return room.CreatorPlayerId == Me ? room : null;
|
||||
}
|
||||
|
||||
// ── /modify (general fields) ─────────────────────────────────────────
|
||||
@@ -306,29 +298,14 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok(new { success = true, error = "" });
|
||||
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
var acceptedRoles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Accepted)
|
||||
.Select(r => r.Role)
|
||||
.ToListAsync();
|
||||
int role = (room?.CreatorPlayerId == playerId)
|
||||
? 30
|
||||
: acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max();
|
||||
int role = (room?.CreatorPlayerId == playerId) ? 30 : 0;
|
||||
long permissions = role switch
|
||||
{
|
||||
30 => -1L,
|
||||
20 => 0x0FFFFFFFL,
|
||||
10 => 0x0FFFFFFFL,
|
||||
5 => 0x000000FFL,
|
||||
_ => 0x0000000FL,
|
||||
};
|
||||
// RoomRole.BJICCBAKLAF.CAN_USE_SHARE_CAM = 18 in the
|
||||
// 2020.12 decompile. Public rooms should allow desktop/share
|
||||
// screens without requiring host/mod/co-owner, while private
|
||||
// rooms keep their explicit role gate.
|
||||
if (room?.Accessibility == PublicAccessibility)
|
||||
{
|
||||
permissions |= CanUseShareCamPermission;
|
||||
}
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
@@ -339,366 +316,4 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
Permissions = permissions,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/rooms/v1/verifyRole</c> — status-only
|
||||
/// gate used by 2020.12 before privileged room actions. Body is
|
||||
/// form or JSON with roomId, role, and optional context.</summary>
|
||||
[HttpPost("api/rooms/v1/verifyRole")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> VerifyRole()
|
||||
{
|
||||
var req = await ReadVerifyRoleRequestAsync();
|
||||
if (req.RoomId <= 0) return BadRequest();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
|
||||
if (room is null) return NotFound();
|
||||
|
||||
var me = Me;
|
||||
var required = RoleRank(req.Role);
|
||||
if (required <= 0) return Ok();
|
||||
|
||||
var isAdmin = await db.Players
|
||||
.Where(p => p.Id == me)
|
||||
.Select(p => p.IsAdmin)
|
||||
.FirstOrDefaultAsync();
|
||||
if (isAdmin || room.CreatorPlayerId == me) return Ok();
|
||||
|
||||
var acceptedRoles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == req.RoomId && r.PlayerId == me && r.Accepted)
|
||||
.Select(r => r.Role)
|
||||
.ToListAsync();
|
||||
var actual = acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max();
|
||||
return actual >= required ? Ok() : Forbid();
|
||||
}
|
||||
|
||||
private sealed class VerifyRoleRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public string Context { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private async Task<VerifyRoleRequest> ReadVerifyRoleRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new VerifyRoleRequest
|
||||
{
|
||||
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
|
||||
?? form["RoomId"].FirstOrDefault(), out var roomId)
|
||||
? roomId
|
||||
: 0,
|
||||
Role = form["role"].FirstOrDefault()
|
||||
?? form["Role"].FirstOrDefault()
|
||||
?? string.Empty,
|
||||
Context = form["context"].FirstOrDefault()
|
||||
?? form["Context"].FirstOrDefault()
|
||||
?? string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var req = await JsonSerializer.DeserializeAsync<VerifyRoleRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return req ?? new VerifyRoleRequest();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new VerifyRoleRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private static int RoomRoleRank(int role) => role switch
|
||||
{
|
||||
0 => 30, // CoOwner
|
||||
1 => 10, // Moderator
|
||||
2 => 20, // Host
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static int RoleRank(string role)
|
||||
{
|
||||
if (int.TryParse(role, out var n)) return n;
|
||||
return role.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"owner" or "creator" or "coowner" or "co-owner" => 30,
|
||||
"host" => 20,
|
||||
"moderator" or "mod" => 10,
|
||||
"member" => 5,
|
||||
"visitor" or "" => 0,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bare-path per-field mutations (2020.12 watch URLs) ─────────────
|
||||
//
|
||||
// The 2020.12 client emits per-field mutation URLs like
|
||||
// POST rooms/{id}/description rather than POST api/rooms/v2/modify
|
||||
// with one field set. Each handler below shares the same
|
||||
// RequireOwnedRoomAsync gate as /modify and returns the same
|
||||
// ToWireRoom shape so the watch's CreateModifyRoomResponse
|
||||
// deserializer reads cleanly.
|
||||
|
||||
public sealed class BareDescriptionRequest { public string? Description { get; set; } }
|
||||
public sealed class BareImageRequest { public string? ImageName { get; set; } }
|
||||
public sealed class BareTagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
|
||||
public sealed class BareIntRequest { public int? Value { get; set; } }
|
||||
public sealed class BareBoolRequest { public bool? Value { get; set; } }
|
||||
public sealed class BareWarningRequest { public int? RoomWarningMask { get; set; } public string? CustomRoomWarning { get; set; } }
|
||||
|
||||
// Each per-field route registers BOTH POST and PUT — the 2020.12
|
||||
// watch's request-builder wraps the HTTP method inside opaque
|
||||
// BPHGKAEDBPE helpers; the existing /name handler at
|
||||
// RoomsController.cs:1455-1456 set the precedent of "register both
|
||||
// because the ISIL is opaque". Same pattern here so the bind never
|
||||
// 405s.
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/description")]
|
||||
[HttpPut("rooms/{roomId:long}/description")]
|
||||
public async Task<IActionResult> BareDescription(long roomId,
|
||||
[FromBody] BareDescriptionRequest? body,
|
||||
[FromForm(Name = "Description")] string? form) =>
|
||||
await ApplyAndReturn(roomId, r => r.Description = body?.Description ?? form ?? r.Description);
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/image")]
|
||||
[HttpPut("rooms/{roomId:long}/image")]
|
||||
public async Task<IActionResult> BareImage(long roomId,
|
||||
[FromBody] BareImageRequest? body,
|
||||
[FromForm(Name = "ImageName")] string? form) =>
|
||||
await ApplyAndReturn(roomId, r => r.ImageName = body?.ImageName ?? form ?? r.ImageName);
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/tags")]
|
||||
[HttpPut("rooms/{roomId:long}/tags")]
|
||||
public async Task<IActionResult> BareTags(long roomId,
|
||||
[FromBody] BareTagsRequest? body,
|
||||
[FromForm(Name = "Tags")] string? form)
|
||||
{
|
||||
var csv = body?.Tags
|
||||
?? (body?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
|
||||
?? form;
|
||||
return await ApplyAndReturn(roomId, r => r.TagsCsv = csv ?? r.TagsCsv);
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/accessibility")]
|
||||
[HttpPut("rooms/{roomId:long}/accessibility")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareAccessibility(long roomId)
|
||||
{
|
||||
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (value is int vv) r.Accessibility = Math.Clamp(vv, 0, 2);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<int?> ReadBareIntAsync(params string[] keys)
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in keys)
|
||||
if (int.TryParse(form[key].FirstOrDefault(), out var value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
if (int.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value;
|
||||
|
||||
if ((Request.ContentLength ?? 0) <= 0) return null;
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
|
||||
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n;
|
||||
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out n)) return n;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
[HttpPost("rooms/{roomId:long}/cloning")]
|
||||
[HttpPut("rooms/{roomId:long}/cloning")]
|
||||
public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body,
|
||||
[FromForm(Name = "CloningAllowed")] bool? form) =>
|
||||
await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var v = body?.Value ?? form;
|
||||
if (v is bool vv) r.CloningAllowed = vv;
|
||||
});
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/automute")]
|
||||
[HttpPut("rooms/{roomId:long}/automute")]
|
||||
public async Task<IActionResult> BareAutomute(long roomId, [FromBody] BareBoolRequest? body,
|
||||
[FromForm(Name = "DisableMicAutoMute")] bool? form) =>
|
||||
await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var v = body?.Value ?? form;
|
||||
if (v is bool vv) r.DisableMicAutoMute = vv;
|
||||
});
|
||||
|
||||
/// <summary><c>rooms/{id}/restrictions</c> — junior / age-related
|
||||
/// restriction toggle. Body bool maps to AllowsJuniors (true = no
|
||||
/// restriction, false = juniors restricted).</summary>
|
||||
[HttpPost("rooms/{roomId:long}/restrictions")]
|
||||
[HttpPut("rooms/{roomId:long}/restrictions")]
|
||||
public async Task<IActionResult> BareRestrictions(long roomId, [FromBody] BareBoolRequest? body,
|
||||
[FromForm(Name = "AllowsJuniors")] bool? form) =>
|
||||
await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var v = body?.Value ?? form;
|
||||
if (v is bool vv) r.AllowsJuniors = vv;
|
||||
});
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/warning")]
|
||||
[HttpPut("rooms/{roomId:long}/warning")]
|
||||
public async Task<IActionResult> BareWarning(long roomId, [FromBody] BareWarningRequest? body) =>
|
||||
await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (body?.RoomWarningMask is int m) r.RoomWarningMask = m;
|
||||
if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning;
|
||||
});
|
||||
|
||||
/// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c>
|
||||
/// — fields not persisted in the 2020 RoomEntity. Owner-gated
|
||||
/// acknowledgement so the watch's settings panel doesn't surface
|
||||
/// a "save failed" error; populated when the columns land.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/comments")]
|
||||
[HttpPut("rooms/{roomId:long}/comments")]
|
||||
[HttpPost("rooms/{roomId:long}/voice_chat_encryption")]
|
||||
[HttpPut("rooms/{roomId:long}/voice_chat_encryption")]
|
||||
public async Task<IActionResult> BareAck(long roomId) =>
|
||||
await ApplyAndReturn(roomId, _ => { /* no-op: not modelled yet */ });
|
||||
|
||||
/// <summary><c>rooms/{id}/modify</c> — bare-path equivalent
|
||||
/// of <c>api/rooms/v2/modify</c>. Reuses <see cref="Modify"/> by
|
||||
/// forwarding the body with RoomId pre-populated from the URL.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/modify")]
|
||||
[HttpPut("rooms/{roomId:long}/modify")]
|
||||
public async Task<IActionResult> BareModify(long roomId, [FromBody] ModifyRoomRequest req)
|
||||
{
|
||||
req.RoomId = roomId;
|
||||
return await Modify(req);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/clone</c> — bare-path alias of
|
||||
/// <c>api/rooms/v1/clone</c>. The actual clone logic lives on
|
||||
/// <see cref="DorkNet.Server.Controllers.API.Rooms.V2.RoomsController"/>;
|
||||
/// this handler proxies via the service so we don't duplicate the
|
||||
/// substantial clone-rooms logic.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/clone")]
|
||||
public async Task<IActionResult> BareClone(long roomId,
|
||||
[FromBody] CloneBareRequest? body,
|
||||
[FromForm(Name = "Name")] string? formName)
|
||||
{
|
||||
var newName = body?.Name ?? formName ?? string.Empty;
|
||||
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (source is null) return NotFound();
|
||||
if (!source.CloningAllowed && source.CreatorPlayerId != Me)
|
||||
return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
newName = $"{source.Name}Copy";
|
||||
else
|
||||
newName = newName.Trim();
|
||||
|
||||
// Uniqueness — append a numeric suffix until free.
|
||||
var baseName = newName;
|
||||
int suffix = 1;
|
||||
while (await db.Rooms.AnyAsync(r => r.Name == newName))
|
||||
{
|
||||
suffix += 1;
|
||||
newName = $"{baseName}{suffix}";
|
||||
}
|
||||
|
||||
var clone = new RoomEntity
|
||||
{
|
||||
Name = newName,
|
||||
Description = source.Description,
|
||||
CreatorPlayerId = Me,
|
||||
ImageName = source.ImageName,
|
||||
State = 0,
|
||||
Accessibility = source.Accessibility,
|
||||
SupportsLevelVoting = source.SupportsLevelVoting,
|
||||
IsAGRoom = false,
|
||||
IsDormRoom = false,
|
||||
CloningAllowed = source.CloningAllowed,
|
||||
SupportsVRLow = source.SupportsVRLow,
|
||||
SupportsMobile = source.SupportsMobile,
|
||||
SupportsScreens = source.SupportsScreens,
|
||||
SupportsWalkVR = source.SupportsWalkVR,
|
||||
SupportsTeleportVR = source.SupportsTeleportVR,
|
||||
AllowsJuniors = source.AllowsJuniors,
|
||||
RoomWarningMask = source.RoomWarningMask,
|
||||
CustomRoomWarning = source.CustomRoomWarning,
|
||||
DisableMicAutoMute = source.DisableMicAutoMute,
|
||||
LocationReplicationId = source.LocationReplicationId,
|
||||
TagsCsv = source.TagsCsv,
|
||||
CurrentDataBlobName = source.CurrentDataBlobName,
|
||||
};
|
||||
db.Rooms.Add(clone);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(Services.RoomService.ToWireRoom(clone));
|
||||
}
|
||||
public sealed class CloneBareRequest { public string? Name { get; set; } }
|
||||
|
||||
// ── Bare-path bans aliases ─────────────────────────────────────────
|
||||
|
||||
public sealed class BareBanRequest { public long PlayerId { get; set; } public int BanType { get; set; } public DateTime? Until { get; set; } public string? Reason { get; set; } }
|
||||
|
||||
[HttpGet("rooms/{roomId:long}/bans")]
|
||||
public Task<IActionResult> BareBansList(long roomId) => RoomBansList(roomId);
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/bans")]
|
||||
public Task<IActionResult> BareBan(long roomId, [FromBody] BareBanRequest req) =>
|
||||
BanFromRoom(new BanFromRoomRequest
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = req.PlayerId,
|
||||
BanType = req.BanType,
|
||||
Until = req.Until,
|
||||
Reason = req.Reason,
|
||||
});
|
||||
|
||||
[HttpDelete("rooms/{roomId:long}/bans/{playerId:long}")]
|
||||
public async Task<IActionResult> BareUnban(long roomId, long playerId)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(roomId);
|
||||
if (room is null) return NotFound();
|
||||
var rows = await db.RoomBans
|
||||
.Where(b => b.RoomId == roomId && b.BannedPlayerId == playerId)
|
||||
.ToListAsync();
|
||||
if (rows.Count == 0) return Ok(new { Removed = 0 });
|
||||
db.RoomBans.RemoveRange(rows);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Removed = rows.Count });
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/bans/import")]
|
||||
public Task<IActionResult> BareBansImport(long roomId, [FromBody] ImportRoomBansRequest req)
|
||||
{
|
||||
req.RoomId = roomId;
|
||||
return ImportRoomBans(req);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ApplyAndReturn(long roomId, Action<RoomEntity> mutator)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(roomId);
|
||||
if (room is null) return NotFound();
|
||||
mutator(room);
|
||||
room.UpdatedAt = DateTime.UtcNow;
|
||||
try { await db.SaveChangesAsync(); }
|
||||
catch (DbUpdateException) { return Conflict(new { Result = 2 }); }
|
||||
return Ok(Services.RoomService.ToWireRoom(room));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,18 +115,4 @@ public class SanitizeController(ILogger<SanitizeController> logger) : Controller
|
||||
?? textForm ?? textQuery ?? string.Empty;
|
||||
return Ok(ProfanityFilter.IsClean(input));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/sanitize/isPure</c> — December 2020 route
|
||||
/// used by room/settings forms. Its importer reads an <c>IsPure</c>
|
||||
/// object field, unlike the newer bare-bool route above.</summary>
|
||||
[HttpPost("api/sanitize/isPure")]
|
||||
public IActionResult IsPure(
|
||||
[FromBody] SanitizeBody? body,
|
||||
[FromForm(Name = "Text")] string? textForm,
|
||||
[FromQuery(Name = "text")] string? textQuery)
|
||||
{
|
||||
var input = body?.Text ?? body?.Input ?? body?.Value
|
||||
?? textForm ?? textQuery ?? string.Empty;
|
||||
return Ok(new { IsPure = ProfanityFilter.IsClean(input) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,66 +113,23 @@ public class PurchaseController(StoreService store, ILogger<PurchaseController>
|
||||
public IActionResult CleanupPending() =>
|
||||
Ok(new { success = true, error = "" });
|
||||
|
||||
/// <summary>POST <c>subscription/v1/*</c> — Rec Room+. The private
|
||||
/// server has no real billing integration, so buying RR+ is a yes/ack
|
||||
/// operation and cancel is also acknowledged. Returning an active
|
||||
/// subscription object keeps the client out of platform IAP UI.</summary>
|
||||
/// <summary>POST <c>subscription/v1/cancel</c> — Rec Room+
|
||||
/// subscription cancel button. Verified URL at
|
||||
/// Commerce.txt:2188. The private server has no real billing
|
||||
/// integration (no Stripe, no platform IAP receipts) so the
|
||||
/// subscription is purely cosmetic — we ack the cancel so the
|
||||
/// watch flips its UI to "canceled" and stops the recurring
|
||||
/// renewal nag. If a real subscription entity ever lands, this
|
||||
/// is where to expire its CurrentPeriodEnd.</summary>
|
||||
[HttpPost("/subscription/v1")]
|
||||
[HttpPost("/subscription/v1/purchase")]
|
||||
[HttpPost("/subscription/v1/buy")]
|
||||
[HttpPost("/subscription/v1/cancel")]
|
||||
[HttpPost("/api/subscription/v1")]
|
||||
[HttpPost("/api/subscription/v1/purchase")]
|
||||
[HttpPost("/api/subscription/v1/buy")]
|
||||
[HttpPost("/api/subscription/v1/cancel")]
|
||||
public IActionResult SubscriptionAck()
|
||||
public IActionResult CancelSubscription()
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
logger.LogInformation("[subscription] ack requested by player {Pid}", pid);
|
||||
return Ok(SubscriptionResponse(pid));
|
||||
}
|
||||
|
||||
[HttpGet("/subscription/v1")]
|
||||
[HttpGet("/subscription/v1/current")]
|
||||
[HttpGet("/subscription/v1/mine")]
|
||||
[HttpGet("/api/subscription/v1")]
|
||||
[HttpGet("/api/subscription/v1/current")]
|
||||
[HttpGet("/api/subscription/v1/mine")]
|
||||
public IActionResult SubscriptionStatus() =>
|
||||
Ok(SubscriptionResponse(this.CurrentPlayerId() ?? 0));
|
||||
|
||||
private static Dictionary<string, object?> SubscriptionResponse(long playerId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var pascal = new Dictionary<string, object?>
|
||||
{
|
||||
["SubscriptionId"] = $"dorknet-rrplus-{playerId}",
|
||||
["RecNetPlayerId"] = playerId,
|
||||
["IsActive"] = true,
|
||||
["StartedAt"] = now,
|
||||
["CurrentPeriodEnd"] = now.AddYears(10),
|
||||
["RenewalDate"] = now.AddYears(10),
|
||||
};
|
||||
var camel = new Dictionary<string, object?>
|
||||
{
|
||||
["subscriptionId"] = $"dorknet-rrplus-{playerId}",
|
||||
["recNetPlayerId"] = playerId,
|
||||
["isActive"] = true,
|
||||
["startedAt"] = now,
|
||||
["currentPeriodEnd"] = now.AddYears(10),
|
||||
["renewalDate"] = now.AddYears(10),
|
||||
};
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["success"] = true,
|
||||
["error"] = "",
|
||||
["IsSubscribed"] = true,
|
||||
["isSubscribed"] = true,
|
||||
["Active"] = true,
|
||||
["active"] = true,
|
||||
["Subscription"] = pascal,
|
||||
["subscription"] = camel,
|
||||
};
|
||||
logger.LogInformation("[subscription] cancel requested by player {Pid}",
|
||||
this.CurrentPlayerId() ?? 0);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
private async Task<Data.Entities.StoreItemEntity?> ResolveItemAsync(PurchaseRequest body)
|
||||
|
||||
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Controllers.API.RoomKeys;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
@@ -52,6 +51,7 @@ public class StorefrontsBuyController(
|
||||
public int GiftContext { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyItem")]
|
||||
[HttpPost("api/storefronts/v2/buyItem")]
|
||||
public async Task<IActionResult> BuyItem([FromBody] RequestPurchaseItemDto req)
|
||||
{
|
||||
@@ -103,7 +103,6 @@ public class StorefrontsBuyController(
|
||||
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out avatarItemDesc);
|
||||
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
|
||||
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
|
||||
StoreService.TryGetEquipmentPayload(item.Slug, out var equipmentPrefabName, out var equipmentModificationGuid);
|
||||
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
|
||||
{
|
||||
avatarItemType = 1;
|
||||
@@ -117,8 +116,6 @@ public class StorefrontsBuyController(
|
||||
AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType,
|
||||
AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc,
|
||||
ConsumableItemDesc = consumableItemDesc,
|
||||
EquipmentPrefabName = equipmentPrefabName,
|
||||
EquipmentModificationGuid = equipmentModificationGuid,
|
||||
CurrencyType = req.CurrencyType,
|
||||
Currency = 0,
|
||||
Xp = 0,
|
||||
@@ -161,8 +158,6 @@ public class StorefrontsBuyController(
|
||||
AvatarItemType = gift.AvatarItemType,
|
||||
AvatarItemDesc = avatarItemDesc,
|
||||
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
|
||||
EquipmentPrefabName = gift.EquipmentPrefabName,
|
||||
EquipmentModificationGuid = gift.EquipmentModificationGuid,
|
||||
Message = gift.Message ?? string.Empty,
|
||||
});
|
||||
}
|
||||
@@ -176,6 +171,7 @@ public class StorefrontsBuyController(
|
||||
isGift ? null : gift.Id));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyTier")]
|
||||
[HttpPost("api/storefronts/v2/buyTier")]
|
||||
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
|
||||
{
|
||||
@@ -191,6 +187,7 @@ public class StorefrontsBuyController(
|
||||
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyElite")]
|
||||
[HttpPost("api/storefronts/v2/buyElite")]
|
||||
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
|
||||
{
|
||||
@@ -201,47 +198,6 @@ public class StorefrontsBuyController(
|
||||
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v1/buyRoomKey</c> — purchase a
|
||||
/// persisted room key. The 2020.12 client sends roomKeyId and the
|
||||
/// price it displayed so we reject stale-price purchases.</summary>
|
||||
[HttpGet("api/storefronts/v1/buyRoomKey")]
|
||||
public async Task<IActionResult> BuyRoomKey(
|
||||
[FromQuery] long roomKeyId,
|
||||
[FromQuery] int requestedPrice)
|
||||
{
|
||||
var pid = Me;
|
||||
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == roomKeyId && !k.IsDeleted);
|
||||
if (key is null)
|
||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.DoesNotExist, null, await level.GetBalanceAsync(pid, 2)));
|
||||
if (key.CreatorPlayerId == pid)
|
||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PlayerAlreadyOwns, key, await level.GetBalanceAsync(pid, 2)));
|
||||
if (requestedPrice != key.Price)
|
||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.InvalidParameters, key, await level.GetBalanceAsync(pid, 2)));
|
||||
|
||||
var existing = await db.RoomKeyPurchases
|
||||
.FirstOrDefaultAsync(p => p.RoomKeyId == key.Id && p.PlayerId == pid);
|
||||
if (existing is not null)
|
||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PlayerAlreadyOwns, key, await level.GetBalanceAsync(pid, 2)));
|
||||
|
||||
var balance = await level.GetBalanceAsync(pid, 2);
|
||||
if (balance < key.Price)
|
||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PurchaseFailed, key, balance));
|
||||
|
||||
var newBalance = await level.GrantCurrencyAsync(pid, 2, -key.Price, $"buyRoomKey:{key.Id}");
|
||||
db.RoomKeyPurchases.Add(new RoomKeyPurchaseEntity
|
||||
{
|
||||
RoomKeyId = key.Id,
|
||||
PlayerId = pid,
|
||||
PaidPrice = key.Price,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (key.CreatorPlayerId != pid && key.Price > 0)
|
||||
await level.GrantCurrencyAsync(key.CreatorPlayerId, 2, key.Price, $"sellRoomKey:{key.Id}");
|
||||
|
||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task GrantWardrobeAsync(long pid, string guid)
|
||||
@@ -346,13 +302,10 @@ public class StorefrontsBuyController(
|
||||
var avatarItemType = 0;
|
||||
var avatarItemDesc = string.Empty;
|
||||
var consumableItemDesc = string.Empty;
|
||||
var equipmentPrefabName = string.Empty;
|
||||
var equipmentModificationGuid = string.Empty;
|
||||
if (grantedSlug is not null)
|
||||
{
|
||||
StoreService.TryGetAvatarItemPayload(grantedSlug, out avatarItemType, out avatarItemDesc);
|
||||
StoreService.TryGetConsumableItemDesc(grantedSlug, out consumableItemDesc);
|
||||
StoreService.TryGetEquipmentPayload(grantedSlug, out equipmentPrefabName, out equipmentModificationGuid);
|
||||
if (StoreService.TryGetHairDyePayload(grantedSlug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
|
||||
{
|
||||
avatarItemType = 1;
|
||||
@@ -364,18 +317,8 @@ public class StorefrontsBuyController(
|
||||
var giftPackage = new
|
||||
{
|
||||
// REQUIRED via Util.GetKey — KeyNotFoundException on miss.
|
||||
// Verified keys at NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946:
|
||||
// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
|
||||
// ConsumableItemDesc, EquipmentPrefabName,
|
||||
// EquipmentModificationGuid, Platform, PlatformsToSpawnOn,
|
||||
// BalanceType, CurrencyType, Currency, Xp, GiftRarity,
|
||||
// GiftContext. Missing any → "Failed to download gifts:
|
||||
// Malformed Response" (and on a purchase path, the buy
|
||||
// times out because the response never deserializes).
|
||||
Id = giftId,
|
||||
Platform = 0,
|
||||
PlatformsToSpawnOn = -1,
|
||||
BalanceType = 0,
|
||||
Xp = 0,
|
||||
Level = 1,
|
||||
GiftRarity = 0,
|
||||
@@ -386,8 +329,8 @@ public class StorefrontsBuyController(
|
||||
AvatarItemDesc = avatarItemDesc,
|
||||
AvatarItemDescOrHairDyeDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc),
|
||||
ConsumableItemDesc = consumableItemDesc,
|
||||
EquipmentPrefabName = equipmentPrefabName,
|
||||
EquipmentModificationGuid = equipmentModificationGuid,
|
||||
EquipmentPrefabName = string.Empty,
|
||||
EquipmentModificationGuid = string.Empty,
|
||||
CurrencyType = currencyType,
|
||||
Currency = 0,
|
||||
Message = string.Empty,
|
||||
@@ -415,16 +358,4 @@ public class StorefrontsBuyController(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static object RoomKeyPurchaseResponse(RoomKeyStatus status, RoomKeyEntity? key, long balance) => new
|
||||
{
|
||||
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
|
||||
BalanceUpdateResponse = new
|
||||
{
|
||||
Balance = balance,
|
||||
CurrencyType = 2,
|
||||
BalanceType = 0,
|
||||
BalanceUpdates = Array.Empty<object>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,23 +50,6 @@ public class StorefrontsController(
|
||||
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v1/p2p/betaEnabled</c> —
|
||||
/// raw Boolean gate for player-to-player sales. Returning false
|
||||
/// disables the beta affordance without lying about catalog state.</summary>
|
||||
[HttpGet("api/storefronts/v1/p2p/betaEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult P2PBetaEnabled() => Content("false", "application/json");
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v1/trialInvention/duration</c>.
|
||||
/// The 2020.12 client's public promise projects the Duration
|
||||
/// property from this object, so the wire body is not a bare int.</summary>
|
||||
[HttpGet("api/storefronts/v1/trialInvention/duration")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult TrialInventionDuration() => Ok(new
|
||||
{
|
||||
Duration = Math.Max(0, config.GetValue("Store:TrialInventionDurationSeconds", 300)),
|
||||
});
|
||||
|
||||
/// <summary>GET api/storefronts/v3/season — items currently in the
|
||||
/// season-pass storefront. Empty for now since we don't run
|
||||
/// seasons; returning an empty list rather than the catch-all so
|
||||
@@ -133,15 +116,16 @@ public class StorefrontsController(
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
return Ok(new[]
|
||||
// 2018 client (RecNet.cs:94092, KKBGNKJHGJF<BalanceResponseDTO>) parses a
|
||||
// SINGLE object reading Balance(long)+CurrencyType(int) off the root —
|
||||
// reading "Balance" off an array root throws. This branch is 2018-only,
|
||||
// so the per-currency balance returns a single object, not an array.
|
||||
return Ok(new
|
||||
{
|
||||
new
|
||||
{
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = 0,
|
||||
Balance = balance,
|
||||
Platform = 0,
|
||||
},
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = 0,
|
||||
Balance = balance,
|
||||
Platform = 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user