mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b9afcd0c6 | |||
| 4f41253cdc | |||
| 9342e2da99 | |||
| a249a50952 | |||
| 243a61ada1 | |||
| 02b26c872d | |||
| f9b16b12b1 | |||
| 362509ef1b | |||
| 4ef463f664 | |||
| cce20ebdb6 | |||
| 2b87f3d7c5 | |||
| 5b13e24880 | |||
| 482c73e1bc | |||
| 64746cdaa2 | |||
| feccd439d4 | |||
| f27d16c4fe | |||
| 657402b613 | |||
| 73d77dffb6 | |||
| 92bd22d164 | |||
| 2375db50ed | |||
| 4d370bfcb9 | |||
| eb2fb0e1f1 | |||
| 9b39c2e8e4 | |||
| 31c53b9070 | |||
| fe1c2c53e3 | |||
| 97e0fa5716 | |||
| 4d58130acd | |||
| 3c0dd64293 | |||
| 399930c6ab | |||
| 3aa5739691 | |||
| e2f169cdea | |||
| 4d14ede3dc | |||
| 8dfe4c8d63 | |||
| 31e2cf7389 | |||
| f0d24d0858 | |||
| d040d5b93b | |||
| 376f7e917c | |||
| 13351a1847 | |||
| 3f4a1b0eb0 | |||
| 2b7246abe9 | |||
| f97fbc4f3e | |||
| 0011ae1039 | |||
| ff0fb65505 | |||
| 7e765bb536 | |||
| 78fcec7263 | |||
| 93833c8b93 | |||
| f581d03bec | |||
| d2bf57a20c | |||
| ef440bedaa | |||
| 810ba0992c | |||
| 218d92f856 | |||
| a2feb780c2 | |||
| f1a616e9c1 | |||
| 143d3a9a07 | |||
| c4e22e1326 | |||
| 7535cd823f | |||
| 24c8a920e4 | |||
| 96eb368d41 | |||
| 27c560906f | |||
| 5dbcf50d37 | |||
| e18e167660 | |||
| e404ed8352 | |||
| 35af6ff29e | |||
| 171afa8ce4 | |||
| 3d089f6e32 | |||
| 3480ca21fa | |||
| 18989fa6ec | |||
| fad8d81961 | |||
| 6db7f270a4 | |||
| 43fe3572c9 | |||
| dfd0d44641 | |||
| b8f1ab91fe | |||
| 23d328180d | |||
| c5db16eeb0 | |||
| cd3f8a2da3 | |||
| e29382f611 | |||
| 9c1caa936c | |||
| 18a5391e37 | |||
| f331530974 | |||
| f5061f014b | |||
| 4edf25b2d5 |
@@ -0,0 +1,72 @@
|
||||
# Keep the build context small + reproducible. Anything not strictly
|
||||
# needed for `dotnet restore && dotnet publish` is excluded.
|
||||
|
||||
# Build artefacts
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/*.user
|
||||
**/*.suo
|
||||
|
||||
# Dev-only configs (Local layer must NEVER ship into a container —
|
||||
# secrets are passed by env vars on Coolify).
|
||||
**/appsettings.Local.json
|
||||
**/appsettings.Development.json
|
||||
|
||||
# Local runtime data
|
||||
**/data/
|
||||
**/server.log
|
||||
**/*.log
|
||||
|
||||
# IDE noise
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Reverse-engineering scratch (not needed for production server)
|
||||
Cpp2ILOutput/
|
||||
Cpp2IL_CS/
|
||||
Cpp2IL_ISIL/
|
||||
Il2CppDump/
|
||||
RR_protos/
|
||||
RR_ProtobufDefinitions.zip
|
||||
GameAssembly.dll
|
||||
global-metadata.dat
|
||||
il2cpp.h
|
||||
dump.cs
|
||||
script.json
|
||||
RoomPersistenceManager_decompiled.txt
|
||||
|
||||
# Local TLS certs (mkcert) — never copy into the image
|
||||
*.pem
|
||||
hosts.dorknet.txt
|
||||
|
||||
# Tools that aren't part of the server runtime
|
||||
tools/
|
||||
Tools/
|
||||
|
||||
# Vite SPA build leftovers — node_modules is huge and the dist is
|
||||
# rebuilt fresh by the npm-build stage in the Dockerfile, so neither
|
||||
# should ship into the docker context. The source under admin-ui/src
|
||||
# and admin-ui/{package.json,vite.config.ts,...} is what we copy in.
|
||||
DorkNet.Server/admin-ui/node_modules/
|
||||
DorkNet.Server/admin-ui/dist/
|
||||
DorkNet.Server/admin-ui/.vite/
|
||||
DorkNet.Server/wwwroot/admin/assets/
|
||||
DorkNet.Server/wwwroot/admin/index.html
|
||||
DorkNet.Server/wwwroot/admin/*.map
|
||||
|
||||
# Top-level docs / planning
|
||||
README.md
|
||||
LICENSE
|
||||
NOTICE
|
||||
BUILD_PROPER_PLAN.md
|
||||
nginx.conf
|
||||
|
||||
# Other stages of the workflow
|
||||
*.md
|
||||
docs/
|
||||
@@ -1,82 +0,0 @@
|
||||
# Push / PR CI on `main` — fast feedback that the launcher + CLI
|
||||
# both still build, and that the CLI publishes for the cross-platform
|
||||
# RIDs the release workflow will package. Does not produce release
|
||||
# artifacts — only proves things compile.
|
||||
#
|
||||
# Runs on:
|
||||
# - every push to main
|
||||
# - every pull request targeting main
|
||||
# - manual workflow_dispatch (so you can re-run after a flake without
|
||||
# pushing an empty commit)
|
||||
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: "9.0.x"
|
||||
DOTNET_NOLOGO: "true"
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
|
||||
|
||||
jobs:
|
||||
# ── Launcher needs Windows for the WPF target framework ────────────
|
||||
build-launcher:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore launcher/DorkNet.Launcher.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build launcher/DorkNet.Launcher.csproj -c Release --no-restore
|
||||
|
||||
# A self-contained publish is the closest thing to "what we ship"
|
||||
# without invoking Inno Setup, which would require an extra
|
||||
# toolchain install for every CI run.
|
||||
- name: Publish (smoke)
|
||||
run: dotnet publish launcher/DorkNet.Launcher.csproj -c Release -r win-x64 --self-contained true --no-restore
|
||||
|
||||
# ── CLI builds on Ubuntu — proves the shared Backend/*.cs files
|
||||
# truly don't have Windows-only dependencies. The release pipeline
|
||||
# later publishes the matrix; here we just confirm restore + build
|
||||
# on the canonical Linux runner.
|
||||
build-cli:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
rid: [win-x64, linux-x64, linux-arm64, osx-x64, osx-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj
|
||||
|
||||
- name: Publish ${{ matrix.rid }}
|
||||
run: |
|
||||
dotnet publish cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj \
|
||||
-c Release \
|
||||
-r ${{ matrix.rid }} \
|
||||
--self-contained true \
|
||||
--no-restore
|
||||
|
||||
- name: Smoke-test --help (host RID only)
|
||||
if: matrix.rid == 'linux-x64'
|
||||
run: |
|
||||
out=cli/DorkNet.ServerCli/bin/Release/net9.0/${{ matrix.rid }}/publish/dorknet-server
|
||||
chmod +x "$out"
|
||||
"$out" --help
|
||||
@@ -1,221 +0,0 @@
|
||||
# Tagged release pipeline for the host-side tooling on `main` —
|
||||
# the Windows GUI launcher (installer + portable) and the
|
||||
# cross-platform dorknet-server CLI. Triggers on a SemVer tag like
|
||||
# v0.2.0 (or v0.2.0-rc1 for pre-releases).
|
||||
#
|
||||
# Produces, on each release:
|
||||
# * dorknet-setup-<version>.exe Inno Setup installer
|
||||
# * dorknet.exe Portable single-file Windows build
|
||||
# * dorknet-server-win-x64.zip Cross-platform server CLI, per RID
|
||||
# * dorknet-server-linux-x64.tar.gz
|
||||
# * dorknet-server-linux-arm64.tar.gz
|
||||
# * dorknet-server-osx-x64.tar.gz
|
||||
# * dorknet-server-osx-arm64.tar.gz
|
||||
# * SHA256SUMS Checksums of all of the above
|
||||
#
|
||||
# All of these get attached to the GitHub release the tag creates.
|
||||
|
||||
name: release-launcher
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to release (e.g. 0.2.0). Leave blank to use the workflow's git tag."
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: write # required to create / update GitHub releases
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: "9.0.x"
|
||||
DOTNET_NOLOGO: "true"
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
|
||||
|
||||
jobs:
|
||||
# ── Resolve the version once and pass it to all downstream jobs ─────
|
||||
resolve-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
steps:
|
||||
- id: v
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -n "${{ inputs.version }}" ]]; then
|
||||
v="${{ inputs.version }}"
|
||||
else
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
fi
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "Releasing version: $v"
|
||||
|
||||
# ── Windows: GUI launcher (installer + portable) ─────────────────────
|
||||
build-launcher-windows:
|
||||
needs: resolve-version
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup --no-progress -y
|
||||
|
||||
- name: Build installer + portable exe
|
||||
shell: pwsh
|
||||
run: |
|
||||
./installer/build-installer.ps1 -Version "${{ needs.resolve-version.outputs.version }}"
|
||||
|
||||
- name: Stage Windows artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$stage = "artifacts"
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
# Installer
|
||||
Copy-Item "installer/out/dorknet-setup-${{ needs.resolve-version.outputs.version }}.exe" $stage
|
||||
# Portable single-file build (same publish output the installer wraps)
|
||||
Copy-Item "launcher/bin/Release/net9.0-windows/win-x64/publish/dorknet.exe" $stage
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-windows
|
||||
path: artifacts/*
|
||||
|
||||
# ── Cross-platform server CLI, one RID per matrix entry ─────────────
|
||||
build-server-cli:
|
||||
needs: resolve-version
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- rid: win-x64
|
||||
os: windows-2022
|
||||
ext: zip
|
||||
- rid: linux-x64
|
||||
os: ubuntu-latest
|
||||
ext: tar.gz
|
||||
- rid: linux-arm64
|
||||
os: ubuntu-latest
|
||||
ext: tar.gz
|
||||
- rid: osx-x64
|
||||
os: macos-latest
|
||||
ext: tar.gz
|
||||
- rid: osx-arm64
|
||||
os: macos-latest
|
||||
ext: tar.gz
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Publish dorknet-server for ${{ matrix.rid }}
|
||||
shell: bash
|
||||
run: |
|
||||
v="${{ needs.resolve-version.outputs.version }}"
|
||||
dotnet publish cli/DorkNet.ServerCli \
|
||||
-c Release \
|
||||
-r "${{ matrix.rid }}" \
|
||||
--self-contained true \
|
||||
/p:Version="$v" \
|
||||
/p:AssemblyVersion="${v}.0" \
|
||||
/p:FileVersion="${v}.0" \
|
||||
/p:InformationalVersion="$v"
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
src="cli/DorkNet.ServerCli/bin/Release/net9.0/${{ matrix.rid }}/publish"
|
||||
out="artifacts"
|
||||
mkdir -p "$out"
|
||||
case "${{ matrix.ext }}" in
|
||||
zip)
|
||||
# Use PowerShell on Windows so the archive is consumable
|
||||
# by Windows Explorer + curl without weird zip flags.
|
||||
pwsh -Command "Compress-Archive -Path '$src/*' -DestinationPath '$out/dorknet-server-${{ matrix.rid }}.zip' -Force"
|
||||
;;
|
||||
tar.gz)
|
||||
# chmod +x first — single-file publish drops a binary
|
||||
# without the executable bit on macOS in some scenarios.
|
||||
chmod +x "$src"/dorknet-server || true
|
||||
tar -czf "$out/dorknet-server-${{ matrix.rid }}.tar.gz" -C "$src" .
|
||||
;;
|
||||
esac
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-cli-${{ matrix.rid }}
|
||||
path: artifacts/*
|
||||
|
||||
# ── Aggregate, checksum, attach to the release ──────────────────────
|
||||
release:
|
||||
needs: [resolve-version, build-launcher-windows, build-server-cli]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: List artifacts + compute SHA256SUMS
|
||||
run: |
|
||||
cd dist
|
||||
ls -la
|
||||
sha256sum * > SHA256SUMS
|
||||
cat SHA256SUMS
|
||||
|
||||
- name: Build release notes
|
||||
id: notes
|
||||
run: |
|
||||
v="${{ needs.resolve-version.outputs.version }}"
|
||||
{
|
||||
echo "## DorkNet launcher \`v$v\`"
|
||||
echo ""
|
||||
echo "Host-side release. For the matching server + client patcher per Rec Room build, see the per-version branch tags."
|
||||
echo ""
|
||||
echo "### Downloads"
|
||||
echo ""
|
||||
echo "| Platform | Use | Asset |"
|
||||
echo "| --- | --- | --- |"
|
||||
echo "| Windows | One-click installer (Start menu + auto-updates) | \`dorknet-setup-$v.exe\` |"
|
||||
echo "| Windows | Portable single-file launcher | \`dorknet.exe\` |"
|
||||
echo "| Linux x64 | Headless host CLI | \`dorknet-server-linux-x64.tar.gz\` |"
|
||||
echo "| Linux arm64 | Headless host CLI | \`dorknet-server-linux-arm64.tar.gz\` |"
|
||||
echo "| macOS Intel | Headless host CLI | \`dorknet-server-osx-x64.tar.gz\` |"
|
||||
echo "| macOS Apple Silicon | Headless host CLI | \`dorknet-server-osx-arm64.tar.gz\` |"
|
||||
echo "| Windows | Headless host CLI (same as GUI) | \`dorknet-server-win-x64.zip\` |"
|
||||
echo ""
|
||||
echo "Checksums in \`SHA256SUMS\`."
|
||||
echo ""
|
||||
echo "### Setup"
|
||||
echo ""
|
||||
echo "- Windows GUI: [easy-setup.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/easy-setup.md)"
|
||||
echo "- Linux / macOS: [server-cli.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/server-cli.md)"
|
||||
echo "- Photon setup: [photon-setup.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/photon-setup.md)"
|
||||
} > notes.md
|
||||
|
||||
- name: Create / update GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Prerelease if the tag has a -suffix (e.g. v0.2.0-rc1).
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
body_path: notes.md
|
||||
generate_release_notes: false
|
||||
files: |
|
||||
dist/*
|
||||
# The installer registers HKCU\Software\DorkNet\Version on
|
||||
# install — that means the auto-updater sees this version
|
||||
# only after the tag actually exists on GitHub, so we let
|
||||
# the action create the release on first push of the tag.
|
||||
fail_on_unmatched_files: true
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Release (december-2020-12-18)
|
||||
|
||||
# Auto-publishes dorknet-server + dorknet-clientpatch zips to a
|
||||
# GitHub Release on tag push. The launcher (on the public main
|
||||
# branch) fetches the most-recent tag matching its prefix and
|
||||
# downloads these assets, so the tag format MUST start with the
|
||||
# branch's release_tag_prefix declared in versions.json.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v1-december-*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
|
||||
env:
|
||||
VERSION_KEY: december_2020_12_18
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
# ── Server self-contained publish ────────────────────────────────
|
||||
- name: Publish DorkNet.Server (win-x64, self-contained)
|
||||
run: |
|
||||
dotnet publish DorkNet.Server/DorkNet.Server.csproj `
|
||||
-c Release -r win-x64 --self-contained `
|
||||
-p:PublishSingleFile=true `
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||
-p:EnableCompressionInSingleFile=true `
|
||||
-o publish/server
|
||||
|
||||
- name: Zip server publish
|
||||
run: |
|
||||
Compress-Archive -Path publish/server/* `
|
||||
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||
|
||||
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
|
||||
- name: Build client patcher zip
|
||||
run: |
|
||||
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
|
||||
|
||||
# ── Publish both as Release assets ───────────────────────────────
|
||||
- name: Upload Release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||
dist/dorknet-clientpatch-${{ env.VERSION_KEY }}.zip
|
||||
fail_on_unmatched_files: true
|
||||
+80
-65
@@ -1,71 +1,86 @@
|
||||
# Build outputs
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/.vite/
|
||||
**/build/
|
||||
local-releases/
|
||||
**/installer/out/
|
||||
|
||||
# IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
## .NET
|
||||
bin/
|
||||
obj/
|
||||
build/
|
||||
*.user
|
||||
*.suo
|
||||
*.csproj.user
|
||||
|
||||
# Local config (NEVER commit)
|
||||
.claude/
|
||||
appsettings.json
|
||||
appsettings.Local.json
|
||||
appsettings.Development.json
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Runtime data
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
data/*.db.bak.*
|
||||
data/images/
|
||||
keys/
|
||||
**/MelonLoader/UserData/dorknet-clientmod.json
|
||||
**/MelonLoader/Latest.log
|
||||
**/MelonLoader/Logs/
|
||||
**/output_log.txt
|
||||
**/crash_reports/
|
||||
|
||||
# Decompilation dumps — never ship Rec Room IP derivative works
|
||||
Cpp2IL_ISIL/
|
||||
Cpp2IL_CS/
|
||||
**/Recroom_Release_Data/
|
||||
**/Recroom_Release.exe
|
||||
**/GameAssembly.dll
|
||||
**/global-metadata.dat
|
||||
**/version.dll
|
||||
|
||||
# TLS certs / private keys — never ship under any name
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.cer
|
||||
*.pfx
|
||||
*.p12
|
||||
*.jks
|
||||
*.asc
|
||||
*.gpg
|
||||
*.kdbx
|
||||
|
||||
# Maintainer-side scrub tool — operates ON this repo, must not be IN it
|
||||
tools/scrub-for-public.ps1
|
||||
|
||||
# OS
|
||||
.vs/
|
||||
.vscode/
|
||||
*.csproj.bak
|
||||
dist/
|
||||
## Data / secrets
|
||||
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
||||
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
||||
# case-insensitive filesystems. Use the precise runtime data dirs.
|
||||
DorkNet.Server/bin/Debug/net9.0/data/
|
||||
DorkNet.Server/bin/Release/net9.0/data/
|
||||
# appsettings.Production.json is intentionally versioned — contains
|
||||
# only the Postgres provider switch + Kestrel bind address. All real
|
||||
# secrets come from Coolify env vars at runtime.
|
||||
tmp/
|
||||
## OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test outputs
|
||||
TestResults/
|
||||
*.coverage
|
||||
## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit)
|
||||
dump.cs
|
||||
il2cpp.h
|
||||
script.json
|
||||
stringliteral.json
|
||||
Il2CppDump/
|
||||
Cpp2ILOutput/
|
||||
Cpp2IL_CS/
|
||||
Cpp2IL_ISIL/
|
||||
RoomPersistenceManager_decompiled.txt
|
||||
|
||||
## Per-machine editor / agent settings
|
||||
.claude/
|
||||
global-metadata.dat
|
||||
GameAssembly.dll
|
||||
_wildcard.rec.net+1.pem
|
||||
_wildcard.rec.net+1-key.pem
|
||||
dist$ZIPNAME.zip
|
||||
|
||||
## Vite admin SPA build output — derived from DorkNet.Server/admin-ui/
|
||||
## via `npm run build` (local) or the admin-ui Dockerfile stage (deploy).
|
||||
## Each build emits a new hash (assets/index-<hash>.js) so committing
|
||||
## these files leaves a stale index.html → JS hash mismatch the next
|
||||
## time someone rebuilds. Keep the source committed, regenerate output.
|
||||
DorkNet.Server/wwwroot/admin/index.html
|
||||
DorkNet.Server/wwwroot/admin/assets/
|
||||
DorkNet.Server/wwwroot/admin/*.map
|
||||
DorkNet.Server/admin-ui/node_modules/
|
||||
DorkNet.Server/admin-ui/dist/
|
||||
DorkNet.Server/admin-ui/.vite/
|
||||
DorkNet.Server/admin-ui/tsconfig.tsbuildinfo
|
||||
|
||||
## Public-facing site (Vite + React, DorkNet.Server/site/)
|
||||
## 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/*.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/**
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
# Branches
|
||||
|
||||
DorkNet keeps one long-lived branch for each supported Rec Room client
|
||||
build. Each branch carries the server and client patcher for that
|
||||
build's wire protocol. Pick the branch that matches your install.
|
||||
|
||||
## Active branches
|
||||
|
||||
### `december-2020-12-18`
|
||||
|
||||
- **Client build:** Rec Room 2020.12.18
|
||||
- **Status:** active
|
||||
- **Notes:**
|
||||
- Single EF Initial migration; SQLite + Postgres both work out of the
|
||||
box.
|
||||
- Includes the chat-threads, clubs, playlists, weekly-challenge, and
|
||||
room-keys subsystems added late-2020.
|
||||
- Server's `Versions/Late2020VersionPlugin` is the active version
|
||||
plugin; client header `X-DorkNet-Version: december_2020_12_18`.
|
||||
|
||||
### `march-2020-03-10`
|
||||
|
||||
- **Client build:** Rec Room 2020.03.10 (also serves 2020.03.06)
|
||||
- **Status:** active
|
||||
- **Notes:**
|
||||
- Earlier wire shape — no clubs, no playlists, simpler store catalog.
|
||||
- Diverges from `december-2020-12-18` by ~170 files; cannot be
|
||||
unified with the plugin abstraction alone.
|
||||
|
||||
Setup starts the same way for both branches: pick the branch, then
|
||||
follow that branch's own deploy notes. See
|
||||
[docs/advanced-setup.md](docs/advanced-setup.md).
|
||||
|
||||
## How to pick
|
||||
|
||||
If you don't know which Rec Room build you have, check:
|
||||
|
||||
- **Steam**: right-click *Rec Room* → Properties → Local files →
|
||||
Browse → look at the `Recroom_Release_Data/StreamingAssets/version.txt`
|
||||
if present, or note the install timestamp.
|
||||
- **Manual install** (the typical DorkNet setup path): the build is in
|
||||
the folder name (`RecRoom-2020.12.18`, `RecRoom-2020.03.10`, etc.).
|
||||
- **Easy launcher**: auto-detects the build and picks the branch.
|
||||
|
||||
## Machine-readable manifest
|
||||
|
||||
[`versions.json`](versions.json) carries the same information in a
|
||||
format the Easy launcher and install scripts can read directly. When a
|
||||
new version branch lands, add it there too.
|
||||
|
||||
## Adding a new version branch
|
||||
|
||||
1. Land the server code on a new orphan branch named `<month>-<build>`
|
||||
(e.g. `august-2021-08-04`).
|
||||
2. Add a row to the active-branches list above.
|
||||
3. Add the matching entry to `versions.json`.
|
||||
4. Add a `docs/<month>-setup.md` setup guide.
|
||||
5. Tag a release on the new branch so the Easy launcher's
|
||||
release-fetcher picks it up.
|
||||
|
||||
Branches are intentionally long-lived — once a build is supported, its
|
||||
branch keeps getting patches against the same wire protocol. Don't
|
||||
rebase across branches; cherry-pick when a fix applies to multiple.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Contributing to DorkNet
|
||||
|
||||
Thanks for your interest. A few ground rules before you open a PR.
|
||||
|
||||
## Code style
|
||||
|
||||
- **C#**: nullable enabled. Match the file you're editing. No regions, no
|
||||
multi-paragraph XML docs, no async-without-await.
|
||||
- **TypeScript / React**: existing patterns — function components, named
|
||||
exports, tailwind classes, no CSS modules. On a version branch, lint
|
||||
with `npm run lint` from `DorkNet.Server/admin-ui/`.
|
||||
- **No AI-co-author trailers in commit messages.** Use a real attribution
|
||||
line or none at all.
|
||||
|
||||
## Branches and commits
|
||||
|
||||
- `main` is protected; PR from a feature branch.
|
||||
- Conventional commits welcome but not required. One-line summary should
|
||||
describe the *why*, not the *what* — the diff already shows what.
|
||||
|
||||
## Scope of contributions we welcome
|
||||
|
||||
- Bug fixes against documented behavior
|
||||
- Feature parity with the 2020 client (anything reverse-engineered cleanly)
|
||||
- Performance work
|
||||
- Better docs, screenshots, video walkthroughs
|
||||
- New translations of the admin UI
|
||||
|
||||
## Scope of contributions we don't accept
|
||||
|
||||
- **Rec Room game assets, decompiled source, or modified game binaries.**
|
||||
We never ship Rec Room IP. See [DISCLAIMER.md](DISCLAIMER.md). PRs that
|
||||
add these files will be closed.
|
||||
- **Cheats, exploits, or features that would harm other public Rec Room
|
||||
servers.** This project exists to let people self-host the 2020 backend
|
||||
for themselves; it isn't a vehicle for griefing live games.
|
||||
- **Crypto / NFT / monetization plugins.** Hard no.
|
||||
|
||||
## Reporting security issues
|
||||
|
||||
Don't open a public issue. Email the maintainer at the address in the repo
|
||||
metadata, or use GitHub's private vulnerability reporting.
|
||||
|
||||
## Setting up a dev environment
|
||||
|
||||
See [docs/advanced-setup.md](docs/advanced-setup.md) for the full server
|
||||
stack. The server and admin UI live on the version branches, not on
|
||||
`main`. For just hacking on the admin UI after you check out a version
|
||||
branch:
|
||||
|
||||
```bash
|
||||
cd DorkNet.Server/admin-ui
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Points at `http://localhost:5000` by default — start the server first.
|
||||
@@ -1,51 +0,0 @@
|
||||
# Disclaimer
|
||||
|
||||
DorkNet is an independent, fan-made, clean-room reimplementation of
|
||||
backend protocols used by old **2020 Rec Room** builds. The supported
|
||||
client builds live on separate branches, currently March 2020 and
|
||||
December 2020.
|
||||
|
||||
It is **not** affiliated with, endorsed by, or sponsored by:
|
||||
|
||||
- Rec Room Inc.
|
||||
- Against Gravity (the original developer)
|
||||
- Photon (Exit Games GmbH)
|
||||
- Steam / Valve Corporation
|
||||
- Meta / Oculus
|
||||
|
||||
**Rec Room™**, the Rec Room logo, and all in-game art, audio, models,
|
||||
locations, characters, and brand assets are the intellectual property of
|
||||
Rec Room Inc. and are used here only for descriptive interoperability.
|
||||
|
||||
## What DorkNet does and doesn't ship
|
||||
|
||||
DorkNet's repository contains:
|
||||
- Original server code (ASP.NET Core)
|
||||
- Original admin tooling (React)
|
||||
- Original MelonLoader mod (C#)
|
||||
- Patcher scripts that operate on the user's own local Rec Room install
|
||||
|
||||
DorkNet's repository **does not** contain:
|
||||
- The Rec Room game executable, assets, or any redistributable Rec Room IP
|
||||
- Decompiled Rec Room source or IL2CPP dumps
|
||||
- Patched copies of `Recroom_Release.exe`, `global-metadata.dat`, or any
|
||||
Rec Room game file
|
||||
|
||||
You must acquire the matching 2020 Rec Room client yourself through
|
||||
whatever means are legal in your jurisdiction, for example SteamDB depot
|
||||
tools against an account that owns Rec Room. The patcher runs on your
|
||||
machine, modifies files locally, and never uploads the game anywhere.
|
||||
|
||||
## Use at your own risk
|
||||
|
||||
This is hobby software. There are no warranties of any kind. If running
|
||||
DorkNet violates a license you agreed to (e.g. Steam Subscriber Agreement,
|
||||
Rec Room Terms of Service), that's between you and the rights holder.
|
||||
DorkNet's authors take no responsibility for consequences arising from
|
||||
your use of the software.
|
||||
|
||||
## Takedown requests
|
||||
|
||||
If you represent Rec Room Inc. or another rights holder and wish to discuss
|
||||
this project, please open an issue on the public repository and we will
|
||||
respond promptly.
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# 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.
|
||||
#
|
||||
# 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 NODE_VERSION=20
|
||||
|
||||
# ── admin SPA build stage ──────────────────────────────────────────
|
||||
# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a
|
||||
# dedicated node image because the .NET SDK image doesn't ship npm.
|
||||
# Output is dropped into /wwwroot/admin and copied into the .NET build
|
||||
# stage just before `dotnet publish`, which is configured to skip its
|
||||
# own inline npm step via -p:SkipAdminUIBuild=true.
|
||||
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||
WORKDIR /ui
|
||||
# Lockfile first for layer caching: when only application source
|
||||
# changes, the npm install step stays cached.
|
||||
COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./
|
||||
RUN npm install --no-fund --no-audit --loglevel=error
|
||||
# Now the source. vite.config.ts writes to ../wwwroot/admin/, which
|
||||
# from /ui is /wwwroot/admin/ — pre-create the parent so Vite's
|
||||
# emptyOutDir won't refuse to create above its cwd.
|
||||
COPY DorkNet.Server/admin-ui/ ./
|
||||
RUN mkdir -p /wwwroot/admin \
|
||||
&& npm run build \
|
||||
&& ls -la /wwwroot/admin \
|
||||
&& ls -la /wwwroot/admin/assets
|
||||
|
||||
# ── public-facing site build stage ─────────────────────────────────
|
||||
# Mirrors the admin-ui stage. Source under DorkNet.Server/site,
|
||||
# output emitted to /wwwroot/site for the .NET stage to copy.
|
||||
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 \
|
||||
&& ls -la /wwwroot/site
|
||||
|
||||
# ── build stage ────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy csproj files first so `dotnet restore` is cached when only
|
||||
# source files change. Web SDK pulls in transitive packages from the
|
||||
# DorkNet.Models project ref, so its csproj needs to be present too.
|
||||
COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/
|
||||
COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/
|
||||
RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj
|
||||
|
||||
# Now copy the rest of the .NET source, then drop the prebuilt admin
|
||||
# SPA into wwwroot/admin so the publish step ships it as static content.
|
||||
COPY DorkNet.Server/ DorkNet.Server/
|
||||
COPY DorkNet.Models/ DorkNet.Models/
|
||||
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
|
||||
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
|
||||
|
||||
# Publish. SkipAdminUIBuild=true bypasses the csproj target that would
|
||||
# otherwise try to invoke npm (which this image doesn't have).
|
||||
RUN dotnet publish DorkNet.Server/DorkNet.Server.csproj \
|
||||
-c Release \
|
||||
--no-restore \
|
||||
-p:SkipAdminUIBuild=true \
|
||||
-o /app/publish
|
||||
|
||||
# ── runtime stage ──────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
|
||||
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
|
||||
# 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.
|
||||
# Coolify mounts persistent volumes with write permission for this UID.
|
||||
USER app
|
||||
|
||||
COPY --from=build --chown=app:app /app/publish .
|
||||
|
||||
# Postgres provider is selected by appsettings.Production.json.
|
||||
# ASPNETCORE_ENVIRONMENT defaults to Production in the aspnet image
|
||||
# but we set it explicitly for clarity.
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||
ASPNETCORE_URLS=http://0.0.0.0:8080 \
|
||||
DOTNET_RUNNING_IN_CONTAINER=true \
|
||||
DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# /healthz returns 503 until the migration + seed pass complete on
|
||||
# the singleton holding the pg_advisory_xact_lock; other replicas
|
||||
# block on the lock during the same window. Coolify's default 30s
|
||||
# probe interval is fine.
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD curl --fail --silent http://localhost:8080/healthz || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "DorkNet.Server.dll"]
|
||||
@@ -0,0 +1,39 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<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>
|
||||
@@ -0,0 +1,34 @@
|
||||
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!;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Foundation;
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.Hosting;
|
||||
|
||||
namespace DorkNet.AdminMobile;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using UIKit;
|
||||
|
||||
namespace DorkNet.AdminMobile;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# DorkNet Admin Mobile
|
||||
|
||||
Native Android/iOS admin app for DorkNet, built with .NET MAUI.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,71 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,71 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,71 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,71 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,147 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == '' and '$(TargetFrameworks)' == ''">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>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
MelonLoader 0.6.x build of the DorkNet client patches. Resolves
|
||||
every game-side type at runtime via AccessTools.TypeByName, so
|
||||
Rec Room's IL2CPP-derived interop assemblies (Assembly-CSharp,
|
||||
Photon3Unity3D, UnityEngine.*) are NOT needed at compile time —
|
||||
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=...
|
||||
-->
|
||||
<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')">
|
||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Unzip SourceFiles="$(MelonLoaderZip)"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
OverwriteReadOnlyFiles="true" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,2666 @@
|
||||
// DorkNet client mod (MelonLoader IL2CPP). Applies the minimum set of
|
||||
// Harmony patches needed for the 2020 watch to connect to a DorkNet
|
||||
// server:
|
||||
//
|
||||
// 1. URI rewrite — `.rec.net` → user-configured host
|
||||
// 2. Photon AppId — override the baked-in Photon Cloud AppId
|
||||
// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth
|
||||
// 4. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
//
|
||||
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
|
||||
// install-melon.ps1 wrapper writes that file on install; you can also
|
||||
// edit it by hand and relaunch the client.
|
||||
//
|
||||
// Why string-based AccessTools lookups instead of typeof(Il2Cpp...):
|
||||
// MelonLoader's Il2CppInterop adds an `Il2Cpp` prefix to game-side
|
||||
// namespaces (so `RecNet.Core` becomes `Il2CppRecNet.Core`) but the
|
||||
// exact policy for globally-namespaced types like `PhotonNetwork` and
|
||||
// for nested types varies across MelonLoader minor versions. Looking
|
||||
// types up by their original game-side name via AccessTools.TypeByName
|
||||
// (which searches every loaded assembly) keeps this file working
|
||||
// regardless of the prefix scheme, and avoids needing a verified
|
||||
// Il2CppAssemblies directory just to write the source.
|
||||
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.ClientMod;
|
||||
|
||||
public class Mod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||
internal static Mod? Instance;
|
||||
internal static string? DiagnosticsLogPath;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _networkPatchesRegistered;
|
||||
private bool _joinTracePatchesRegistered;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private int _diagnosticRetryFrame;
|
||||
private bool _firstUpdateLogged;
|
||||
|
||||
// Config backed by a plain JSON file under MelonLoader/UserData.
|
||||
// The defaults below match the recommended install, so dropping this
|
||||
// mod in without writing a config file still gives working behaviour.
|
||||
public static class Cfg
|
||||
{
|
||||
public static string ServerHost = "localhost";
|
||||
public static string PhotonAppId = "";
|
||||
public static string PhotonVoiceAppId = "";
|
||||
// Forced PhotonServerSettings.PreferredRegion (with HostType
|
||||
// switched to PhotonCloud) so two watches don't auto-ping
|
||||
// themselves into different "best" regions and end up in
|
||||
// parallel Photon rooms sharing only a name. PhotonPatches
|
||||
// .ApplyForcedRegion prefers the server-told region from
|
||||
// Matchmaking.LocalRoomInstance.PhotonRegionId when available
|
||||
// and falls back to this config value on initial boot before
|
||||
// any /goto has returned. Default matches the server's
|
||||
// appsettings.json Photon:CloudRegion ("eu").
|
||||
public static string PhotonCloudRegion = "eu";
|
||||
// Photon Custom Auth injector parked 2026-05-28; see
|
||||
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
|
||||
public static bool EnableTlsTrustBypass = true;
|
||||
// Log-only trace for the stuck first-dorm "Change Username?"
|
||||
// dialog. When true, RegistrationPatches hooks the dorm
|
||||
// registration-prompt flow + the confirm-dialog buttons and writes
|
||||
// what fires to MelonLoader/UserData/dorknet-diagnostics.log, so a
|
||||
// brand-new account that reproduces the freeze tells us exactly
|
||||
// where it breaks (does the click reach Button_Affirmative? does
|
||||
// the prompt even run?). No behaviour change — purely diagnostic.
|
||||
// See RegistrationPatches and the registration site in
|
||||
// OnLateInitializeMelon.
|
||||
public static bool TraceRegistrationDialog = true;
|
||||
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
|
||||
// (normally dev-gated) and silence CheatManager's tamper detectors
|
||||
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
|
||||
// drop you to the dorm. Default OFF — purely opt-in. The toggle key
|
||||
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
|
||||
// ── Desktop Screen Sharing gadget FPS ──
|
||||
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
|
||||
// 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()
|
||||
{
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet ClientMod loading ===");
|
||||
LoadConfig();
|
||||
RegisterNetworkPatches();
|
||||
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
|
||||
}
|
||||
|
||||
public override void OnLateInitializeMelon()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
|
||||
Log.Msg("=== Registering client patches ===");
|
||||
RegisterNetworkPatches();
|
||||
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
|
||||
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
|
||||
// Hook the watch's own AppSettings + AuthValues builders directly.
|
||||
// Both are private PUNNetworkManager methods with obfuscated 2020.12
|
||||
// names — we know they always run before any Photon connect because
|
||||
// ConnectUsingSettings reads their return values. Postfix logs them
|
||||
// after construction.
|
||||
//
|
||||
// FJOLIPKKIBE returns Photon.Realtime.AppSettings — verified at
|
||||
// PUNNetworkManager.txt:7046. One bool arg (DOJLKMCEFFB — likely
|
||||
// "for voice"). Two call paths in the binary.
|
||||
// EMGGKBALALH returns Photon.Realtime.AuthenticationValues —
|
||||
// verified at PUNNetworkManager.txt:7560. Builds AuthValues with
|
||||
// environment + accessToken AuthGetParameters.
|
||||
//
|
||||
// Quest-Dec uses the same obfuscation seed as PC-Dec, but discover by
|
||||
// return type first so a future platform/reseed does not make these
|
||||
// two critical hooks depend on the private method names.
|
||||
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AppSettings",
|
||||
fallbackMethodName: "FJOLIPKKIBE",
|
||||
postfix: nameof(PhotonPatches.LogAppSettings_Postfix));
|
||||
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AuthenticationValues",
|
||||
fallbackMethodName: "EMGGKBALALH",
|
||||
postfix: nameof(PhotonPatches.LogAuthValues_Postfix));
|
||||
// PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever
|
||||
// string-like arg the IL2CPP method takes (declared `string` in
|
||||
// dump.cs but Il2CppInterop wraps it in a way that breaks
|
||||
// Harmony's IL emit when we declare `string` directly). Using
|
||||
// `object` lets Harmony pass through whatever wrapper the IL2CPP
|
||||
// proxy uses without compile-error.
|
||||
TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed",
|
||||
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix));
|
||||
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
|
||||
// Even when the prefix's return value is "let original run", the
|
||||
// detour itself still has to be JIT-installed when BouncyCastle
|
||||
// does its first handshake, and MonoMod's CompileMethodHook
|
||||
// fatal-CLRs (0x80131506) installing trampolines into these
|
||||
// third-party interface implementations. Only register the
|
||||
// detours if the bypass is actually wanted; on a setup with
|
||||
// valid certs (the default) skip the patch entirely.
|
||||
if (Cfg.EnableTlsTrustBypass)
|
||||
{
|
||||
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
}
|
||||
// Post-save blob-name refresh. The 2020 watch's
|
||||
// RoomPersistenceManager.RoomDataBlobName is only ever set by
|
||||
// /goto-driven flows — neither the SubscriptionUpdateRoom push
|
||||
// nor SubscriptionUpdatePresence pushes the server fires after
|
||||
// save propagate into this field. So the PostSaveReloading
|
||||
// sub-state's MasterReloadRoomAsync deserializes the cached
|
||||
// bytes from the OLD blob name (the one /goto returned at boot),
|
||||
// wiping any MakerPen object the user just placed even though
|
||||
// the new blob is already on S3.
|
||||
//
|
||||
// Verified live: after a dorm save with object placement, server
|
||||
// pushes SubscriptionUpdateRoom + PresenceUpdate both carrying
|
||||
// dataBlob="dorm_p<id>_v(N+1).dat", but the watch's
|
||||
// set_RoomDataBlobName trace immediately after the save shows
|
||||
// old="v(N)" new="v(N)" — no propagation, no fresh download,
|
||||
// no respawn. Leave+return triggers /goto which DOES update
|
||||
// RoomDataBlobName, so the fix is to do the same write
|
||||
// ourselves: when entering the SavingRoomSuccess state on the
|
||||
// RoomPersistenceManager, read the just-pushed
|
||||
// Rooms.LocalRoomScene.DataBlobName and force-set
|
||||
// __instance.RoomDataBlobName to it. That fires the real
|
||||
// setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync
|
||||
// for the new blob → PostSaveReloading picks up the fresh
|
||||
// bytes and respawns the MakerPen objects.
|
||||
TryPatchByName("RecRoom.Persistence.RoomPersistenceManager",
|
||||
"set_RoomDataBlobName",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix));
|
||||
// Intercept the SubscriptionUpdateRoom push BEFORE the watch's
|
||||
// (cachedRoomId, cachedSubRoomId) early-out gate drops it. We
|
||||
// stash the just-arrived Scenes[0].DataBlobName into a static
|
||||
// so the set_RoomDataBlobName hijack can use it — otherwise
|
||||
// LocalRoomDetails / LocalRoomScene never get the new blob
|
||||
// name on the second-or-later push for the same (room, sub)
|
||||
// tuple (= every post-save push in a dorm).
|
||||
TryPatchByName("RecNet.Rooms",
|
||||
"OnSubscriptionUpdateRoom",
|
||||
prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix));
|
||||
// Force AccountExtensions.CanLocalPlayerChat to return true. The
|
||||
// 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote
|
||||
// Coroutine d__53 offset 040) gates on this — if false, the
|
||||
// coroutine bails before raising the Photon RPC, so the sender
|
||||
// doesn't even see their own message. The check ANDs three
|
||||
// signals: LocalAccount.TreatAsJunior (ObscuredBool at offset
|
||||
// 0x40), a PlatformManager flag, and a ModerationManager
|
||||
// singleton field. At least one returns the wrong value against
|
||||
// our server. Force-allow — single-tenant private server, text
|
||||
// moderation isn't a concern.
|
||||
TryPatchByName("RecNet.AccountExtensions",
|
||||
"CanLocalPlayerChat",
|
||||
args: Type.EmptyTypes,
|
||||
postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix));
|
||||
// PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat
|
||||
// font asset's character table via TMP_FontAsset.HasCharacters
|
||||
// to strip glyphs the font can't render. On 2020 Rec Room the
|
||||
// chat font field can be null at the moment this fires (font
|
||||
// asset bundle hasn't loaded yet?) and HasCharacters NREs deep
|
||||
// inside Unity's TMP — which propagates out through the chat
|
||||
// send callback and aborts the RPC. The watch's own ISIL has
|
||||
// "skipping invalid character check" log paths for missing
|
||||
// assets but doesn't actually short-circuit on the null field
|
||||
// we hit. Skip the method entirely; chat lines will just keep
|
||||
// any glyphs the font can't render (most common: emoji).
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RemoveInvalidCharactersFromMessage",
|
||||
prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix));
|
||||
// Trace hooks so we can see exactly where chat dies. Send path:
|
||||
// KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string)
|
||||
// → PlayerEmotes.SendChatEmote(msg, true)
|
||||
// → SendChatEmoteCoroutine d__53 → PurifyString HTTP call
|
||||
// → b__0 callback (error, cleanVersion) — fires Photon RPC
|
||||
// AND local-echoes via ProcessNewChatMessageReceived
|
||||
// → on each receiver (including self): RpcChatEmote(msg) →
|
||||
// ProcessNewChatMessageReceived → ReceiveRoomChat appends to
|
||||
// the chat log buffer.
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"SendChatEmote",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.SendChatEmote_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"ProcessNewChatMessageReceived",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RpcChatEmote",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
|
||||
|
||||
// Diagnostic trace for the stuck first-dorm "Change Username?"
|
||||
// dialog (new accounts can't get past it; "Okay" highlights but
|
||||
// never dismisses the modal, on every device). This is LOG-ONLY —
|
||||
// no behaviour change. It bisects the failure for a brand-new
|
||||
// account that reproduces the freeze, writing to
|
||||
// MelonLoader/UserData/dorknet-diagnostics.log:
|
||||
//
|
||||
// 1. RegistrationModel.IsFullyRegistered() return value — if
|
||||
// false, the dorm prompt path runs; if true, no prompt (so a
|
||||
// freeze here would be something else entirely).
|
||||
// 2. DormroomSceneManager.<PromptForRegistration>b__6_* lambdas —
|
||||
// which prompt branch executed (b__6_1 = a dialog was raised).
|
||||
// 3. ConfirmUIDialog.Button_Affirmative/Negative/NotNow/Cancel —
|
||||
// whether the click actually reaches the dialog's button
|
||||
// handler when the user taps Okay. If these FIRE but the modal
|
||||
// stays, the break is in the resolve/hide completer
|
||||
// (UIDialog`1.KHKDGPHPIND's guard); if they DON'T fire, the
|
||||
// click never routes to the button (input/raycast) and the fix
|
||||
// is elsewhere. Either way the next step is unambiguous.
|
||||
if (Cfg.TraceRegistrationDialog)
|
||||
{
|
||||
TryPatchByName("RRUI.Data.NUX.RegistrationModel",
|
||||
"IsFullyRegistered",
|
||||
args: Type.EmptyTypes,
|
||||
postfix: nameof(RegistrationPatches.IsFullyRegistered_Postfix));
|
||||
|
||||
// The two registration dialogs are raised from
|
||||
// DormroomSceneManager's PromptForRegistration via its
|
||||
// compiler-generated <>c lambdas (b__6_0..b__6_3). Reuse the
|
||||
// same nested-lambda patcher the join trace uses.
|
||||
PatchNestedLambdas("DormroomSceneManager", "PromptForRegistration",
|
||||
nameof(RegistrationPatches.PromptLambda_Prefix));
|
||||
|
||||
// The confirm-dialog buttons. Clean, non-generic, no-arg names
|
||||
// on AGUI.StackedUI.Dialog.ConfirmUIDialog — Okay maps to
|
||||
// Button_Affirmative, the screenshot's only button.
|
||||
foreach (var (method, label) in new[]
|
||||
{
|
||||
("Button_Affirmative", nameof(RegistrationPatches.ButtonAffirmative_Prefix)),
|
||||
("Button_Negative", nameof(RegistrationPatches.ButtonNegative_Prefix)),
|
||||
("Button_NotNow", nameof(RegistrationPatches.ButtonNotNow_Prefix)),
|
||||
("Button_Cancel", nameof(RegistrationPatches.ButtonCancel_Prefix)),
|
||||
})
|
||||
{
|
||||
TryPatchByName("AGUI.StackedUI.Dialog.ConfirmUIDialog",
|
||||
method, args: Type.EmptyTypes, prefix: label);
|
||||
}
|
||||
}
|
||||
|
||||
RegisterJoinTracePatches();
|
||||
|
||||
// Desktop Screen Sharing FPS override. Only register when a target
|
||||
// is set; the postfix on the gadget's Awake rewrites the baked
|
||||
// refresh-frequency field (and, if enabled, raises the Photon
|
||||
// serialization rate so the higher capture rate actually transmits).
|
||||
if (Cfg.DesktopScreenShareFps > 0f)
|
||||
{
|
||||
TryPatchByName("RecRoom.Tools.Productivity.DesktopScreenSharingDisplay",
|
||||
"Awake", args: Type.EmptyTypes,
|
||||
postfix: nameof(ScreenSharePatches.DisplayAwake_Postfix));
|
||||
Log.Msg($"[screenshare] override armed: target {Cfg.DesktopScreenShareFps} fps " +
|
||||
$"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})");
|
||||
}
|
||||
|
||||
TryPatchByName("RecRoom.Tools.MakerPenVisuals",
|
||||
"set_LaserPointerEnabled",
|
||||
args: new[] { typeof(bool) },
|
||||
prefix: nameof(MakerPenGiftPreviewPatches.LaserPointerEnabled_Prefix));
|
||||
|
||||
// RRO quest party-size bump. Prefix the runtime config-apply path so
|
||||
// we can read the config's Name (scope to the allowlisted quests) and
|
||||
// bump GameConfigurationData.TeamConfigurations[].TeamSize before the
|
||||
// team manager / pre-game roster reads it. Only when a target is set.
|
||||
if (Cfg.QuestMaxTeamSize > 0)
|
||||
{
|
||||
// The runtime team cap is built from the baked GameConfigurationAsset
|
||||
// (a readable ScriptableObject) by GameConfigurationAsset.GDECPANDBHJ()
|
||||
// — it reads asset.TeamConfigurations[i].MaxTeamSize (a typed int
|
||||
// struct-array) into the protobuf TeamSize. Prefix that builder and
|
||||
// bump the source array IN PLACE before it converts, scoped by
|
||||
// asset.Name. This avoids the obfuscated protobuf entirely (its
|
||||
// generic RepeatedField getter isn't emitted by Il2CppInterop).
|
||||
// Host-authoritative: the quest host must run the mod. See QuestTeamSize.
|
||||
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "GDECPANDBHJ",
|
||||
prefix: nameof(QuestTeamSize.Build_Prefix));
|
||||
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
|
||||
TryPatchByName("GameSpawnManager", "FKEICBPCMPJ",
|
||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
|
||||
}
|
||||
|
||||
RegisterDiagnostics();
|
||||
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;
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatch("Uri ctor (string)", typeof(Uri), "ctor",
|
||||
new[] { typeof(string) },
|
||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
|
||||
new[] { typeof(string), typeof(UriKind) },
|
||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
|
||||
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
|
||||
complete &= TryPatchHttpManagerStringSendRequestOverloads();
|
||||
complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest",
|
||||
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
|
||||
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
|
||||
|
||||
_networkPatchesRegistered = complete;
|
||||
}
|
||||
|
||||
private bool TryPatchHttpManagerStringSendRequestOverloads()
|
||||
{
|
||||
var type = ResolveType("BestHTTP.HTTPManager");
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string): type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var prefix = GetPatchMethod(nameof(UriPatches.HttpManagerSendRequestString_Prefix));
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != "SendRequest") continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length == 0 || parameters[0].ParameterType != typeof(string)) continue;
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefix));
|
||||
patched++;
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string)");
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (!_firstUpdateLogged)
|
||||
{
|
||||
_firstUpdateLogged = true;
|
||||
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
|
||||
}
|
||||
|
||||
if (_diagnosticGameComplete) return;
|
||||
if (_diagnosticRetryFrame++ > 3600) return;
|
||||
if ((_diagnosticRetryFrame % 60) != 0) return;
|
||||
|
||||
var logMisses = (_diagnosticRetryFrame % 300) == 0;
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses);
|
||||
if (_diagnosticGameComplete)
|
||||
DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered");
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnApplicationQuit");
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
private static void LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Compute the UserData path ourselves rather than calling
|
||||
// MelonLoader.MelonUtils.UserDataDirectory (which is renamed
|
||||
// to MelonEnvironment.UserDataDirectory in newer 0.6.x builds
|
||||
// and shifts again in 0.7.x). Anchoring off the mod DLL's
|
||||
// location keeps this stable across versions: the DLL lives
|
||||
// in <game>/Mods/, so two dirname()'s reach the game root,
|
||||
// then MelonLoader/UserData/ is right next to it.
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
|
||||
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
Directory.CreateDirectory(userData);
|
||||
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
|
||||
var path = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Msg($"[config] no file at {path}; using defaults (ServerHost={Cfg.ServerHost})");
|
||||
return;
|
||||
}
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||
if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
|
||||
if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
|
||||
if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
|
||||
if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||
if (r.TryGetProperty("TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
|
||||
if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (r.TryGetProperty("DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
|
||||
if (r.TryGetProperty("DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||
if (r.TryGetProperty("DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||
if (r.TryGetProperty("DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||
if (r.TryGetProperty("QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||
if (r.TryGetProperty("QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
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();
|
||||
}
|
||||
// "InjectAuthValues" key in the template is now ignored —
|
||||
// see attic/AuthValuesInjector.cs.attic.
|
||||
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
|
||||
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Patch dispatch helpers ────────────────────────────────────────
|
||||
private bool TryPatch(string label, Type target, string methodName,
|
||||
Type[]? args, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = methodName == "ctor"
|
||||
? (MethodBase)target.GetConstructor(args ?? Type.EmptyTypes)!
|
||||
: (args is null ? AccessTools.Method(target, methodName)
|
||||
: AccessTools.Method(target, methodName, args));
|
||||
if (method is null)
|
||||
{
|
||||
// `logMiss` is throttled by the retry-loop caller — once per
|
||||
// 5 seconds in OnUpdate, every time in the one-shot initial
|
||||
// RegisterDiagnostics. Without the gate the per-second retry
|
||||
// loop would spam `[patch-miss]` lines forever for any method
|
||||
// whose name drifted in a newer game build.
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}");
|
||||
return false;
|
||||
}
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {label}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[patch-fail] {label}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the game-side type by its ORIGINAL name (no Il2Cpp prefix)
|
||||
// by walking every loaded assembly. Tries the exact name first, then
|
||||
// the same name with an `Il2Cpp` prefix, then a substring scan as a
|
||||
// last resort — covers all the IL2CPP interop naming schemes the user
|
||||
// might end up with depending on the MelonLoader / Il2CppInterop
|
||||
// version installed.
|
||||
private bool TryPatchByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var label = FormatPatchLabel(typeName, methodName, args);
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
private bool TryPatchDeclaredMethodByReturnType(string typeName, string returnTypeName,
|
||||
string fallbackMethodName,
|
||||
string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
var label = $"{typeName}.<returns {returnTypeName}>";
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
var matches = new List<MethodInfo>();
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters || method.IsSpecialName) continue;
|
||||
if (ReturnTypeMatches(method.ReturnType, returnTypeName)) matches.Add(method);
|
||||
}
|
||||
|
||||
if (matches.Count == 1)
|
||||
{
|
||||
var method = matches[0];
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {typeName}.{method.Name} (returns {returnTypeName})");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[patch-fail] {typeName}.{method.Name} (returns {returnTypeName}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.Count > 1)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var method in matches) names.Add(method.Name);
|
||||
Log.Warning($"[patch-ambiguous] {label}: {string.Join(", ", names)}; falling back to {fallbackMethodName}");
|
||||
}
|
||||
else if (logMiss)
|
||||
{
|
||||
Log.Warning($"[patch-miss] {label}; falling back to {fallbackMethodName}");
|
||||
}
|
||||
|
||||
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
internal static Type? ResolveType(string name)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||
return cached;
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return CacheResolvedType(name, t);
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return CacheResolvedType(name, prefixed);
|
||||
// For namespaced types like "RecNet.Core", also try with
|
||||
// the Il2Cpp prefix on the namespace (i.e. "Il2CppRecNet.Core").
|
||||
var dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
|
||||
if (nsPrefixed is not null) return CacheResolvedType(name, nsPrefixed);
|
||||
}
|
||||
}
|
||||
catch { /* unloaded / dynamic asm — skip */ }
|
||||
}
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var t in GetLoadableTypes(asm))
|
||||
{
|
||||
if (MatchesRequestedType(t, name))
|
||||
return CacheResolvedType(name, t);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Type CacheResolvedType(string name, Type type)
|
||||
{
|
||||
ResolvedTypeCache[name] = type;
|
||||
return type;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
|
||||
{
|
||||
try
|
||||
{
|
||||
return asm.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
var types = new List<Type>();
|
||||
foreach (var t in ex.Types)
|
||||
{
|
||||
if (t is not null) types.Add(t);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesRequestedType(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
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 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}";
|
||||
var names = new List<string>();
|
||||
foreach (var arg in args)
|
||||
names.Add(arg.Name);
|
||||
return $"{typeName}.{methodName}({string.Join(",", names)})";
|
||||
}
|
||||
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
// Look in all three patch holder classes — small enough that a
|
||||
// linear scan is cheaper than per-class lookups.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
}
|
||||
throw new MissingMethodException($"patch method '{name}' not found on any holder");
|
||||
}
|
||||
|
||||
private void RegisterDiagnostics()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostics ===");
|
||||
if (!_diagnosticCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
|
||||
_diagnosticCoreRegistered = true;
|
||||
}
|
||||
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
|
||||
Log.Msg("=== Diagnostics registered ===");
|
||||
}
|
||||
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
|
||||
// CheatManager fires these when its anti-cheat heuristics trip —
|
||||
// logging them surfaces "client thinks it's been tampered with",
|
||||
// a common cause of silent dorm-drops / kicks on a modded client.
|
||||
// Names verified in CheatManager.txt for 2020.12.18; the older
|
||||
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
|
||||
// there's no DeveloperFlag/StreamingAsset callback nor a
|
||||
// BootSequence.RegisterError sink in this build — those legacy
|
||||
// hooks were removed (they only ever logged [patch-miss] forever).
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"OnTimeCheatDetected",
|
||||
"OnObscuredTypeCheatDetected",
|
||||
"OnHeightCheatDetected",
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticByName("CheatManager", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
|
||||
// pure-logging diagnostics that fired during Unity's very first
|
||||
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
|
||||
// methods) MonoMod's lazy CompileMethodHook on the trampoline
|
||||
// crashes the CLR with 0x80131506 the instant Awake JITs. The
|
||||
// hooks gave us no behavioral leverage — only a `[call] …` log
|
||||
// line — so they're disabled. If anyone needs Steam-side visibility
|
||||
// later, route it through the game's own log lines instead.
|
||||
return complete;
|
||||
}
|
||||
|
||||
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var label = FormatPatchLabel(typeName, methodName, args);
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
if (!TryPatchByName(typeName, methodName, args, prefix, postfix, logMiss))
|
||||
return false;
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Patch holders ─────────────────────────────────────────────────────
|
||||
// Each holder is a static class with public static Prefix methods that
|
||||
// Harmony invokes. Kept separate from Mod for readability and so the
|
||||
// reflection lookup in GetPatchMethod stays simple.
|
||||
|
||||
// ── Debug console access ───────────────────────────────────────────────
|
||||
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
|
||||
// dev console (text input + a DebugConsoleCommandConfig command table:
|
||||
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
|
||||
// …). Retail gates it behind a developer flag. This holder force-toggles
|
||||
// its UI on a hotkey and (via the OnLateInitializeMelon patch above) skips
|
||||
// CheatManager's tamper detectors so movement/time commands don't drop you
|
||||
// to the dorm.
|
||||
//
|
||||
// Everything is resolved by reflection at runtime so the SAME code works on
|
||||
// both client builds despite their differing obfuscation:
|
||||
// * The show/hide toggle is the one non-compiler-generated private
|
||||
// instance void(bool) on DebugConsole — ShowInputField on 2020.03,
|
||||
// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated
|
||||
// one, then fall back to that structural match.
|
||||
// * The live component is found via UnityEngine.Object.FindObjectOfType
|
||||
// (name-stable) rather than the SingletonMonoBehaviour<T> accessor.
|
||||
// * Execute(string) keeps its readable name on both builds (kept here as
|
||||
// a scripted-command entry point even though the hotkey drives the UI).
|
||||
internal static class DebugConsolePatches
|
||||
{
|
||||
private static bool _consoleVisible;
|
||||
private static bool _pollErrorLogged;
|
||||
private static int _probeFrame;
|
||||
private static bool _probeDone;
|
||||
|
||||
private static bool _inputResolved;
|
||||
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
|
||||
private static object? _toggleKeyValue; // boxed KeyCode enum value
|
||||
|
||||
private static bool _consoleResolved;
|
||||
private static Type? _consoleType;
|
||||
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||
private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type)
|
||||
|
||||
// Harmony prefix returning false → skips the original CheatManager
|
||||
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_inputResolved) ResolveInput();
|
||||
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
|
||||
// console object actually exists, so we learn that even if the
|
||||
// hotkey never fires.
|
||||
if (!_probeDone && _probeFrame++ > 180)
|
||||
{
|
||||
_probeDone = true;
|
||||
ProbeConsole();
|
||||
}
|
||||
if (_getKeyDown is null || _toggleKeyValue is null) return;
|
||||
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
|
||||
{
|
||||
Mod.Log.Msg($"[debugconsole] toggle key '{Mod.Cfg.DebugConsoleToggleKey}' detected");
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_pollErrorLogged) { _pollErrorLogged = true; Mod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic: reports whether a live DebugConsole component exists and
|
||||
// whether the show/hide method resolved — without needing a keypress.
|
||||
private static void ProbeConsole()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||
try
|
||||
{
|
||||
var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType });
|
||||
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Runs DebugConsole.Execute(command) directly — bypasses the UI/gate
|
||||
// entirely. Handy for scripted one-shots; the hotkey path uses the UI.
|
||||
public static void ExecuteCommand(string command)
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var execute = AccessTools.Method(_consoleType, "Execute", new[] { typeof(string) });
|
||||
if (execute is null) { Mod.Log.Warning("[debugconsole] Execute(string) not found"); return; }
|
||||
execute.Invoke(null, new object[] { command });
|
||||
Mod.Log.Msg($"[debugconsole] executed: {command}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] execute '{command}' failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void ResolveInput()
|
||||
{
|
||||
_inputResolved = true;
|
||||
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
|
||||
var inputType = Mod.ResolveType("UnityEngine.Input");
|
||||
if (keyCodeType is null || inputType is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
try { _toggleKeyValue = Enum.Parse(keyCodeType, Mod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[debugconsole] '{Mod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
|
||||
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
|
||||
}
|
||||
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||
if (_getKeyDown is null) Mod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
|
||||
else Mod.Log.Msg($"[debugconsole] hotkey armed: '{Mod.Cfg.DebugConsoleToggleKey}' (poll live)");
|
||||
}
|
||||
|
||||
private static void ResolveConsole()
|
||||
{
|
||||
_consoleResolved = true;
|
||||
_consoleType = Mod.ResolveType("RecRoom.Debugging.DebugConsole");
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
|
||||
|
||||
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
|
||||
// name, then a structural fallback that fits both builds.
|
||||
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(_consoleType);
|
||||
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
|
||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||
_findObjectOfType = objType is null ? null
|
||||
: AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) });
|
||||
if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found");
|
||||
}
|
||||
|
||||
// The console's show/hide is the single private, instance, void method
|
||||
// taking exactly one bool, excluding compiler-generated members (the
|
||||
// property backing setter on 2020.12 is also void(bool) but is marked
|
||||
// [CompilerGenerated]).
|
||||
private static MethodInfo? FindToggleByShape(Type t)
|
||||
{
|
||||
MethodInfo? found = null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||
if (found is not null) return null; // ambiguous — don't guess
|
||||
found = m;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static void Toggle()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
|
||||
try
|
||||
{
|
||||
var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType });
|
||||
if (instance is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||
return;
|
||||
}
|
||||
// Best-effort: flip the static "show button" enable so the
|
||||
// game's own Update keeps the console available instead of
|
||||
// re-hiding it. Failures are ignored (obfuscated builds).
|
||||
EnableShowButton();
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void EnableShowButton()
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
|
||||
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
|
||||
{
|
||||
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
|
||||
}
|
||||
}
|
||||
catch { /* best-effort — the toggle still works without it */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Dev-menu runtime probe (one-shot diagnostic) ────────────────────────
|
||||
// Answers stripped-vs-gated for the watch developer menu. With a real dev
|
||||
// account + forced flag the menu still doesn't appear, so the gate isn't the
|
||||
// account flag — either the native dev-menu prefab content is stripped from
|
||||
// this retail build, or a build-level flag skips its init. This logs the
|
||||
// actual runtime state once ~5s after load (no behaviour change) so we know
|
||||
// whether to un-gate the game's own dev menu or build our own watch entry.
|
||||
// Called from OnUpdate when Cfg.DiagnoseDevMenu is set; self-gates to run
|
||||
// once. Not a Harmony holder — invoked directly, so it's not registered in
|
||||
// GetPatchMethod.
|
||||
internal static class DevMenuProbe
|
||||
{
|
||||
private static int _frame;
|
||||
private static bool _done;
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
if (_done) return;
|
||||
_frame++;
|
||||
if (_frame < 120) return; // ~2s — let MelonLoader/game boot
|
||||
if ((_frame % 60) != 0) return; // then check ~once per second
|
||||
// Only fire once a REAL, in-scene HomeScreenFlow exists with its
|
||||
// UIBase wired (it gets assigned when the watch StackedUI initialises
|
||||
// in the dorm). The earlier version fired during boot on the loaded
|
||||
// PREFAB (Resources.FindObjectsOfTypeAll returns prefabs/inactive,
|
||||
// whose runtime UIBase is null) and, being one-shot, captured nothing
|
||||
// and never retried. NO timeout now — we wait until the watch is up.
|
||||
var homeType = Mod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? Mod.ResolveType("HomeScreenFlow");
|
||||
var home = homeType != null ? FindLiveHome(homeType) : null;
|
||||
if (home == null)
|
||||
{
|
||||
if ((_frame % 600) == 0)
|
||||
Mod.Log.Msg("[devmenu-probe] waiting for an in-scene HomeScreenFlow — enter your dorm / open the watch…");
|
||||
return;
|
||||
}
|
||||
_done = true;
|
||||
try { Run(homeType!, home); }
|
||||
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] failed: {ex}"); }
|
||||
}
|
||||
|
||||
// The live HomeScreenFlow (UIBase wired), skipping the prefab/inactive
|
||||
// copies FindObjectsOfTypeAll also returns.
|
||||
private static object? FindLiveHome(Type homeType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2 = ToIl2CppType(homeType);
|
||||
if (il2 is null) return null;
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2)?.Invoke(null, new[] { il2 });
|
||||
int n = ArrLen(all);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var o = ArrItem(all, i);
|
||||
if (o is null) continue;
|
||||
if (GetMemberValue(homeType, o, "UIBase", "get_UIBase") != null) return o;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Run(Type homeType, object homeInst)
|
||||
{
|
||||
Mod.Log.Msg("=== [devmenu-probe] runtime dev-UI state ===");
|
||||
ProbeType("RecRoom.Core.WatchUI", "WatchUI", DumpWatchUi);
|
||||
Mod.Log.Msg($"[devmenu-probe] HomeScreenFlow: type={homeType.FullName}, liveInstance=FOUND (UIBase wired)");
|
||||
try { DumpHomeScreen(homeType, homeInst); }
|
||||
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] HomeScreenFlow dump failed: {ex.Message}"); }
|
||||
ProbeType("RecRoom.Debugging.DebugConsole", "DebugConsole", DumpDebugConsole);
|
||||
Mod.Log.Msg("=== [devmenu-probe] done ===");
|
||||
}
|
||||
|
||||
private static void ProbeType(string fullName, string shortName, Action<Type, object?> dump)
|
||||
{
|
||||
var t = Mod.ResolveType(fullName) ?? Mod.ResolveType(shortName);
|
||||
if (t is null) { Mod.Log.Msg($"[devmenu-probe] {shortName}: TYPE NOT FOUND"); return; }
|
||||
var inst = FindOne(t);
|
||||
Mod.Log.Msg($"[devmenu-probe] {shortName}: type={t.FullName}, liveInstance={(inst is null ? "null" : "FOUND")}");
|
||||
try { dump(t, inst); } catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] {shortName} dump failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void DumpHomeScreen(Type t, object? inst)
|
||||
{
|
||||
if (inst is null) { Mod.Log.Msg(" (no HomeScreenFlow instance live yet — open the watch, then re-probe)"); return; }
|
||||
// Il2CppInterop exposes IL2CPP instance fields as PROPERTIES on the
|
||||
// managed proxy, so a plain AccessTools.Field misses them (the earlier
|
||||
// "field not found" was a false negative). Try property first, then
|
||||
// field. NULL => content stripped from the prefab (can't un-gate);
|
||||
// present => only gated.
|
||||
foreach (var name in new[] { "devMenuPanel", "devButtonPrefab", "reportBugButtonSpacer" })
|
||||
{
|
||||
var (found, mt, v) = GetMember(t, inst, name);
|
||||
if (!found) { Mod.Log.Msg($" {name}: <not found as property or field>"); continue; }
|
||||
var extra = "";
|
||||
if (v != null && name == "devMenuPanel")
|
||||
{
|
||||
try { var cc = v.GetType().GetProperty("childCount")?.GetValue(v); if (cc != null) extra = $" childCount={cc}"; } catch { }
|
||||
}
|
||||
Mod.Log.Msg($" {name} ({mt.Name}): {(v is null ? "NULL <-- stripped/unset" : "present")}{extra}");
|
||||
}
|
||||
// Calibration: are ordinary serialized field names exposed at all on
|
||||
// the runtime proxy? If 'profileButton' (a non-dev [SerializeField])
|
||||
// IS found but devMenuPanel isn't, the dev fields are genuinely absent
|
||||
// in this build. If profileButton is ALSO not found, Il2CppInterop
|
||||
// isn't exposing these fields by name (need a different access path).
|
||||
foreach (var cal in new[] { "profileButton", "settingsButton" })
|
||||
{
|
||||
var (cf, cmt, cv) = GetMember(t, inst, cal);
|
||||
Mod.Log.Msg($" [calib] {cal}: {(cf ? $"FOUND ({cmt.Name}, {(cv is null ? "null" : "present")})" : "not found")}");
|
||||
}
|
||||
// Named lookups miss → dump the proxy's actual member names to see
|
||||
// Il2CppInterop's scheme, then enumerate Button3D children directly
|
||||
// (no field names needed) — those are our clone sources for injection.
|
||||
var fs = t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var ps = t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var fn = new List<string>(); foreach (var f in fs) fn.Add(f.Name);
|
||||
var pn = new List<string>(); foreach (var p in ps) pn.Add(p.Name);
|
||||
Mod.Log.Msg($" [members] fields={fn.Count}, props={pn.Count}");
|
||||
if (fn.Count > 0) Mod.Log.Msg($" [fieldnames] {string.Join(", ", fn.GetRange(0, Math.Min(40, fn.Count)))}");
|
||||
if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}");
|
||||
DumpWatchButtons(t, inst);
|
||||
DumpCollections(t, inst, "HomeScreenFlow");
|
||||
DumpStackedUi(t, inst);
|
||||
}
|
||||
|
||||
// Walk HomeScreenFlow -> UIBase (StackedUIBase) and dump the data we need
|
||||
// to build a CUSTOM watch page by cloning a real flow:
|
||||
// * flowResources catalog — the TypeName of every PUSHABLE flow; these
|
||||
// are our clone-target candidates (pick a simple self-contained one).
|
||||
// * the Type->flow cache — what's already instantiated (do NOT mutate
|
||||
// these cached singletons; clone an independent copy instead).
|
||||
// * ScreenHeader presence — proves a cloned flow brings its own title
|
||||
// bar + back button for free.
|
||||
// Names are obfuscated, so we match by SHAPE (array-of-prefab-ref,
|
||||
// Dictionary<Type,flow>) rather than by field name.
|
||||
private static void DumpStackedUi(Type homeType, object homeInst)
|
||||
{
|
||||
var ui = GetMemberValue(homeType, homeInst, "UIBase", "get_UIBase");
|
||||
if (ui is null) { Mod.Log.Msg(" [stackedui] UIBase: null (could not reach StackedUIBase)"); return; }
|
||||
var uiType = ui.GetType();
|
||||
Mod.Log.Msg($" [stackedui] UIBase type={uiType.FullName}");
|
||||
|
||||
var hdr = GetMemberValue(homeType, homeInst, "ScreenHeader", "get_ScreenHeader");
|
||||
if (hdr != null)
|
||||
{
|
||||
var ht = hdr.GetType();
|
||||
var hp = new List<string>(); foreach (var p in ht.GetProperties()) hp.Add(p.Name);
|
||||
Mod.Log.Msg($" [stackedui] home ScreenHeader={ht.Name}; props={string.Join(",", hp.GetRange(0, Math.Min(30, hp.Count)))}");
|
||||
}
|
||||
else Mod.Log.Msg(" [stackedui] home ScreenHeader: null");
|
||||
|
||||
foreach (var f in uiType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
object? v; try { v = f.GetValue(ui); } catch { continue; }
|
||||
if (v is null) continue;
|
||||
int n = ArrLen(v);
|
||||
// Flow-prefab catalog: a non-empty array whose elements expose a
|
||||
// TypeName string (FlowPrefabResourceReference).
|
||||
if (n > 0 && n <= 300 && (v is Array || v.GetType().Name.IndexOf("Array", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
{
|
||||
var first = ArrItem(v, 0);
|
||||
var et = first?.GetType().Name ?? "?";
|
||||
if (et.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
et.IndexOf("Prefab", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
et.IndexOf("Resource", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
et.IndexOf("Reference", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
Mod.Log.Msg($" [stackedui] flow catalog '{f.Name}': {et}[{n}]");
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var e = ArrItem(v, i); if (e is null) continue;
|
||||
Mod.Log.Msg($" [{i}] {ReadStringMember(e)}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Flow cache: Dictionary<Type, StackedUIFlowBase>.
|
||||
var vt = v.GetType();
|
||||
if (vt.IsGenericType && vt.Name.StartsWith("Dictionary"))
|
||||
{
|
||||
var ga = vt.GetGenericArguments();
|
||||
if (ga.Length == 2 && ga[1].Name.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
var cp = vt.GetProperty("Count");
|
||||
Mod.Log.Msg($" [stackedui] flow cache '{f.Name}': Dictionary<{ga[0].Name},{ga[1].Name}> count={(cp?.GetValue(v) as int? ?? -1)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Property by name, else parameterless getter method, else null.
|
||||
private static object? GetMemberValue(Type t, object inst, string prop, string getter)
|
||||
{
|
||||
var (found, _, v) = GetMember(t, inst, prop);
|
||||
if (found && v != null) return v;
|
||||
var m = t.GetMethod(getter, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
|
||||
null, Type.EmptyTypes, null);
|
||||
if (m != null) { try { return m.Invoke(inst, null); } catch { } }
|
||||
return v;
|
||||
}
|
||||
|
||||
// First non-empty string field/property on an object — for obfuscated
|
||||
// wrappers, that's the TypeName of a FlowPrefabResourceReference.
|
||||
private static string ReadStringMember(object o)
|
||||
{
|
||||
var t = o.GetType();
|
||||
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
if (p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0)
|
||||
{ try { if (p.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
|
||||
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
if (f.FieldType == typeof(string))
|
||||
{ try { if (f.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
|
||||
return "(no string member)";
|
||||
}
|
||||
|
||||
// Enumerate Button3D components under the HomeScreenFlow via Unity's
|
||||
// GetComponentsInChildren(Type, includeInactive) — needs no field names.
|
||||
// Confirms we can grab native buttons to clone for the dev entry.
|
||||
private static void DumpWatchButtons(Type homeType, object homeInst)
|
||||
{
|
||||
var btnType = Mod.ResolveType("AGUI.StackedUI.Button3D");
|
||||
if (btnType is null) { Mod.Log.Msg(" [buttons] Button3D type NOT FOUND"); return; }
|
||||
var il2 = ToIl2CppType(btnType);
|
||||
if (il2 is null) { Mod.Log.Msg(" [buttons] Button3D il2cppType null"); return; }
|
||||
MethodInfo? m = null;
|
||||
foreach (var mi in homeType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (mi.Name != "GetComponentsInChildren") continue;
|
||||
var mp = mi.GetParameters();
|
||||
if (mp.Length == 2 && mp[0].ParameterType.IsInstanceOfType(il2) && mp[1].ParameterType == typeof(bool)) { m = mi; break; }
|
||||
}
|
||||
if (m is null) { Mod.Log.Msg(" [buttons] GetComponentsInChildren(Type,bool) not found"); return; }
|
||||
object? arr;
|
||||
try { arr = m.Invoke(homeInst, new object[] { il2, true }); }
|
||||
catch (Exception ex) { Mod.Log.Msg($" [buttons] GetComponentsInChildren threw: {(ex.InnerException ?? ex).Message}"); return; }
|
||||
int n = ArrLen(arr);
|
||||
Mod.Log.Msg($" [buttons] Button3D children: {n}");
|
||||
for (int i = 0; i < n && i < 20; i++)
|
||||
{
|
||||
var b = ArrItem(arr, i);
|
||||
var nm = b?.GetType().GetProperty("name")?.GetValue(b)?.ToString() ?? "?";
|
||||
Mod.Log.Msg($" [{i}] {nm}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ArrLen(object? arr)
|
||||
{
|
||||
if (arr is null) return 0;
|
||||
if (arr is Array a) return a.Length;
|
||||
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||
return (lp?.GetValue(arr) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static object? ArrItem(object? arr, int i)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.GetValue(i);
|
||||
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return gi?.Invoke(arr, new object[] { i });
|
||||
}
|
||||
|
||||
private static (bool found, Type type, object? val) GetMember(Type t, object inst, string name)
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
{ try { return (true, p.PropertyType, p.GetValue(inst)); } catch { } }
|
||||
var f = AccessTools.Field(t, name);
|
||||
if (f != null) { try { return (true, f.FieldType, f.GetValue(inst)); } catch { } }
|
||||
return (false, typeof(object), null);
|
||||
}
|
||||
|
||||
private static void DumpWatchUi(Type t, object? inst)
|
||||
{
|
||||
if (inst is null) { Mod.Log.Msg(" (no WatchUI instance live yet)"); return; }
|
||||
DumpCollections(t, inst, "WatchUI");
|
||||
}
|
||||
|
||||
private static void DumpDebugConsole(Type t, object? inst)
|
||||
{
|
||||
foreach (var name in new[] { "GJIKMPIMDHH", "IEGCNLHPKFH", "ShowButton", "ShowErrorsInHeadset", "ShowUnityDevelopmentConsole" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (p != null && p.CanRead) { Mod.Log.Msg($" static {name} = {p.GetValue(null)}"); continue; }
|
||||
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f != null) Mod.Log.Msg($" static {name} = {f.GetValue(null)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Msg($" {name}: read failed ({ex.Message})"); }
|
||||
}
|
||||
// Does the static command path work without the console UI in-scene?
|
||||
var exec = AccessTools.Method(t, "Execute", new[] { typeof(string) });
|
||||
if (exec is null) { Mod.Log.Msg(" Execute(string): <not found>"); return; }
|
||||
try { exec.Invoke(null, new object[] { "help" }); Mod.Log.Msg(" Execute(\"help\"): invoked OK — static command path is usable"); }
|
||||
catch (Exception ex) { var e = ex.InnerException ?? ex; Mod.Log.Msg($" Execute(\"help\"): threw {e.GetType().Name} ({e.Message})"); }
|
||||
}
|
||||
|
||||
// Log every array / List<> field whose element type mentions Flow/Watch,
|
||||
// with its count — surfaces the dev-flow list (devOnlyFlows-equivalent)
|
||||
// regardless of its obfuscated name, so we can see if dev flows exist.
|
||||
private static void DumpCollections(Type t, object inst, string label)
|
||||
{
|
||||
var members = new List<(string name, object? val)>();
|
||||
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{ try { members.Add((f.Name, f.GetValue(inst))); } catch { } }
|
||||
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{ if (p.CanRead && p.GetIndexParameters().Length == 0) { try { members.Add((p.Name, p.GetValue(inst))); } catch { } } }
|
||||
|
||||
foreach (var (name, v) in members)
|
||||
{
|
||||
if (v is null) continue;
|
||||
int? count = null; string elem = "";
|
||||
if (v is Array arr) { count = arr.Length; elem = v.GetType().GetElementType()?.Name ?? "?"; }
|
||||
else
|
||||
{
|
||||
var ft = v.GetType();
|
||||
if (ft.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(ft))
|
||||
{
|
||||
var cp = ft.GetProperty("Count");
|
||||
if (cp != null && cp.PropertyType == typeof(int))
|
||||
{
|
||||
count = (int?)cp.GetValue(v);
|
||||
var ga = ft.GetGenericArguments();
|
||||
elem = ga.Length > 0 ? ga[0].Name : "?";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count.HasValue &&
|
||||
(elem.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
elem.IndexOf("Watch", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
Mod.Log.Msg($" {label}.{name}: {elem}[{count}]");
|
||||
}
|
||||
}
|
||||
|
||||
// Under Il2CppInterop, UnityEngine.Object.FindObjectOfType takes an
|
||||
// Il2CppSystem.Type (NOT System.Type), so AccessTools.Method(...,
|
||||
// typeof(Type)) misses — that's the "FindObjectOfType(Type) not found"
|
||||
// in the log. Convert the managed Type to an Il2CppSystem.Type via
|
||||
// Il2CppType.From, then use Resources.FindObjectsOfTypeAll (which also
|
||||
// returns INACTIVE objects + loaded prefab instances — important, the
|
||||
// watch UI is usually inactive until opened). Falls back to the
|
||||
// active-only FindObjectOfType.
|
||||
private static object? FindOne(Type t)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppType = ToIl2CppType(t);
|
||||
if (il2cppType is null) return null;
|
||||
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType);
|
||||
var first = FirstOf(all?.Invoke(null, new[] { il2cppType }));
|
||||
if (first != null) return first;
|
||||
|
||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
|
||||
return findOne?.Invoke(null, new[] { il2cppType });
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static object? ToIl2CppType(Type managed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
|
||||
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
|
||||
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
|
||||
if (from is null) return null;
|
||||
var args = from.GetParameters().Length == 2
|
||||
? new object[] { managed, true } : new object[] { managed };
|
||||
return from.Invoke(null, args);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Find a public static method `name` whose single parameter accepts `arg`.
|
||||
private static MethodInfo? OneArgMethod(Type? t, string name, object arg)
|
||||
{
|
||||
if (t is null) return null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != name) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length == 1 && ps[0].ParameterType.IsInstanceOfType(arg)) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// First element of an Il2CppReferenceArray / Array / IList, or null.
|
||||
private static object? FirstOf(object? arr)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.Length > 0 ? a.GetValue(0) : null;
|
||||
var at = arr.GetType();
|
||||
var lenProp = at.GetProperty("Length") ?? at.GetProperty("Count");
|
||||
if (lenProp?.GetValue(arr) is int n && n > 0)
|
||||
{
|
||||
var getItem = at.GetMethod("get_Item", new[] { typeof(int) });
|
||||
if (getItem != null) return getItem.Invoke(arr, new object[] { 0 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Desktop Screen Sharing FPS override ─────────────────────────────────
|
||||
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts the
|
||||
// shared desktop image. Capture cadence is the baked [SerializeField]
|
||||
// float screenShareImageRefreshFrequency; the broadcast tick gates on
|
||||
// if (Time.time - lastSend >= 1f / screenShareImageRefreshFrequency)
|
||||
// (DesktopScreenSharingDisplay.txt:697-703) — so the field is literally the
|
||||
// FPS, with no runtime clamp (the [Range] is editor-only). We rewrite it in
|
||||
// an Awake postfix. The captured frame is transmitted via the component's
|
||||
// IPunObservable OnPhotonSerializeView, which only fires at
|
||||
// PhotonNetwork.SerializationRate (default 10 Hz), so we also lift the
|
||||
// Photon send/serialization rate to cover the target — otherwise a 30 fps
|
||||
// capture still only delivers ~10. Field names are un-obfuscated on both
|
||||
// builds (confirmed in the 2020.12.18 dump), so a plain AccessTools.Field
|
||||
// lookup works.
|
||||
internal static class ScreenSharePatches
|
||||
{
|
||||
private static bool _photonRateApplied;
|
||||
|
||||
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)
|
||||
{
|
||||
RewriteString(ref url, "httpmanager-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpManagerSendRequestObject_Prefix(object request)
|
||||
{
|
||||
RewriteRequestUri(request, "httpmanager-request-rewrite");
|
||||
}
|
||||
|
||||
public static void UriStringCtor_Prefix(ref string uriString)
|
||||
{
|
||||
RewriteString(ref uriString, "uri-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpRequestSend_Prefix(object __instance)
|
||||
{
|
||||
RewriteRequestUri(__instance, "http-rewrite");
|
||||
}
|
||||
|
||||
private static void RewriteRequestUri(object request, string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (uriProp?.GetValue(request) is not Uri uri) return;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
|
||||
uriProp.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[{label}] failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteString(ref string uriString, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uriString)) return;
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return;
|
||||
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||
var rewritten = uriString.Replace(".rec.net", "." + host);
|
||||
if (rewritten == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||
uriString = rewritten;
|
||||
}
|
||||
|
||||
private static Uri? RewriteUri(Uri uri)
|
||||
{
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return null;
|
||||
if (!uri.Host.EndsWith(".rec.net", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var service = uri.Host[..^".rec.net".Length];
|
||||
if (string.IsNullOrWhiteSpace(service)) return null;
|
||||
|
||||
return new UriBuilder(uri)
|
||||
{
|
||||
Host = service + "." + host,
|
||||
}.Uri;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PhotonPatches
|
||||
{
|
||||
public static void PhotonAppIdOverride_Prefix()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return;
|
||||
try
|
||||
{
|
||||
// PhotonNetwork.PhotonServerSettings — accessed via reflection
|
||||
// so the type-name resolution stays in one place. Mutating the
|
||||
// ScriptableObject in-place is what the BepInEx plugin does
|
||||
// too; the Photon Connect path reads AppID / VoiceAppID off
|
||||
// this object immediately after.
|
||||
var photonNetwork = Mod.Instance is null ? null : FindGameType("PhotonNetwork");
|
||||
if (photonNetwork is null) return;
|
||||
var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
|
||||
var settings = settingsProp?.GetValue(null);
|
||||
if (settings is null) return;
|
||||
|
||||
var settingsType = settings.GetType();
|
||||
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
|
||||
var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId");
|
||||
|
||||
var newId = Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
|
||||
|
||||
// Diagnostic: dump every readable AppSettings field before
|
||||
// we mutate, so a Photon Custom Auth failure on first connect
|
||||
// is debuggable from a single log dump. Lists every public
|
||||
// property + field on PhotonServerSettings (Server, Port,
|
||||
// NameServer, AuthMode, HostType, PreferredRegion, AppID,
|
||||
// VoiceAppID, BestRegionSummary, etc.) — covers both 2020.03
|
||||
// (Server/Port) and 2020.12 (AppSettings) layouts.
|
||||
Mod.Log.Msg("[photon-diag] PhotonServerSettings BEFORE override:");
|
||||
DumpSettings(settings, settingsType, indent: " ");
|
||||
|
||||
appIdProp?.SetValue(settings, newId);
|
||||
voiceProp?.SetValue(settings, newVoice);
|
||||
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice}");
|
||||
|
||||
// Force a deterministic Photon region so two watches running
|
||||
// this mod end up on the same Photon master and "JoinByName"
|
||||
// produces ONE shared room (not two parallel ones in eu/us
|
||||
// because each watch auto-picked a different best region).
|
||||
//
|
||||
// The 2020 watch's default HostType is BestRegion(4) — every
|
||||
// launch pings all Photon Cloud regions and picks the lowest
|
||||
// latency. Two players geographically apart hit different
|
||||
// "best" regions; same room name, two regions = two rooms.
|
||||
// Server-side our matchmaking response carries
|
||||
// `photonRegionId` but the watch's Photon code never reads
|
||||
// it; the only consumer is Matchmaking.SetPhotonRegionPings
|
||||
// which only reports pings *back* to the server.
|
||||
//
|
||||
// Strategy: switch HostType to PhotonCloud(1) and set
|
||||
// PreferredRegion. Prefer the server-told region (the most
|
||||
// recent Matchmaking.LocalRoomInstance.PhotonRegionId, if
|
||||
// any) over the config default — that way a server-side
|
||||
// region change propagates without redeploying the mod.
|
||||
ApplyForcedRegion(settings, settingsType);
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-appid] override failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
|
||||
/// and writes <c>PreferredRegion</c>. Honors any region the server
|
||||
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
|
||||
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
|
||||
/// instance has been received yet (initial boot).</summary>
|
||||
private static void ApplyForcedRegion(object settings, Type settingsType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hostTypeProp = settingsType.GetField("HostType")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType");
|
||||
var preferredProp = settingsType.GetField("PreferredRegion")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion");
|
||||
if (hostTypeProp is null || preferredProp is null)
|
||||
{
|
||||
Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// ServerSettings.HostingOption.PhotonCloud = 1 (verified in
|
||||
// Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum
|
||||
// type from the field/property so we don't depend on a
|
||||
// specific Il2Cpp proxy namespace.
|
||||
Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)hostTypeProp).PropertyType;
|
||||
Type? regionEnum = (preferredProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)preferredProp).PropertyType;
|
||||
|
||||
// Prefer server-provided region over config. We read it via
|
||||
// the same reflection style as the existing patches so the
|
||||
// Il2Cpp type-name suffix doesn't bite us.
|
||||
string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us";
|
||||
|
||||
object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud
|
||||
object regionValue;
|
||||
try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us");
|
||||
regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1
|
||||
region = "us";
|
||||
}
|
||||
|
||||
if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue);
|
||||
else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue);
|
||||
if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue);
|
||||
else ((PropertyInfo)preferredProp).SetValue(settings, regionValue);
|
||||
|
||||
Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Reads
|
||||
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> as a
|
||||
/// lowercase enum name (eu, us, asia, …). Returns null if no room
|
||||
/// instance has been received yet, or if any link in the chain is
|
||||
/// null. Server-told region wins over the static config default
|
||||
/// because the server is authoritative about which Photon region
|
||||
/// hosts the room the watch is about to join.</summary>
|
||||
private static string? ReadServerProvidedRegion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var matchmaking = FindGameType("RecNet.Matchmaking");
|
||||
var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localRoom is null) return null;
|
||||
var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom);
|
||||
if (regionObj is null) return null;
|
||||
// CloudRegionCode enum.ToString() gives the lowercase region
|
||||
// name (eu, us, asia, …) — see CloudRegionCode.cs.
|
||||
return regionObj.ToString();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// AuthValuesInjector_Prefix parked 2026-05-28 — never wired to a
|
||||
// harmony hook in this branch anyway. See
|
||||
// attic/AuthValuesInjector.cs.attic.
|
||||
|
||||
/// <summary>Postfix on <c>PUNNetworkManager.FJOLIPKKIBE</c> — the
|
||||
/// 2020.12 watch's private AppSettings builder. <c>__result</c> is the
|
||||
/// <c>Photon.Realtime.AppSettings</c> it just built.
|
||||
///
|
||||
/// **CRITICAL OVERRIDE**: replaces <c>AppIdRealtime</c> and
|
||||
/// <c>AppIdVoice</c> with our configured values before the AppSettings
|
||||
/// reaches <c>PhotonNetwork.ConnectUsingSettings</c>. The 2020.12
|
||||
/// watch hardcodes Rec Room's official AppIds as IL2CPP string
|
||||
/// literals (verified at <c>PUNNetworkManager.txt:7205,7208</c>:
|
||||
/// <c>"9372aa8d-..."</c> and <c>"e93ae440-..."</c>). Patching
|
||||
/// resources.assets at offsets 0x7962480 / 0x79624ac doesn't help —
|
||||
/// those are unrelated leftover strings, NOT the AppIds the SDK
|
||||
/// actually reads. The only reliable swap point is here, after
|
||||
/// FJOLIPKKIBE constructs the AppSettings object but before the
|
||||
/// LoadBalancingClient consumes it.
|
||||
///
|
||||
/// Then dumps every readable field/property for diagnostics.</summary>
|
||||
public static void LogAppSettings_Postfix(object __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__result is null) { Mod.Log.Msg("[photon-diag] FJOLIPKKIBE returned null AppSettings"); return; }
|
||||
|
||||
// ── AppId injection ────────────────────────────────────────
|
||||
var t = __result.GetType();
|
||||
var newPun = string.IsNullOrEmpty(Mod.Cfg.PhotonAppId) ? null : Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId)
|
||||
? (newPun ?? null)
|
||||
: Mod.Cfg.PhotonVoiceAppId;
|
||||
if (newPun is not null)
|
||||
{
|
||||
var realtimeProp = t.GetProperty("AppIdRealtime") ?? t.GetProperty("AppIdFusion");
|
||||
var oldPun = realtimeProp?.GetValue(__result) as string;
|
||||
realtimeProp?.SetValue(__result, newPun);
|
||||
Mod.Log.Msg($"[photon-override] AppIdRealtime: {oldPun} → {newPun}");
|
||||
}
|
||||
if (newVoice is not null)
|
||||
{
|
||||
var voiceProp = t.GetProperty("AppIdVoice");
|
||||
var oldVoice = voiceProp?.GetValue(__result) as string;
|
||||
voiceProp?.SetValue(__result, newVoice);
|
||||
Mod.Log.Msg($"[photon-override] AppIdVoice: {oldVoice} → {newVoice}");
|
||||
}
|
||||
// Also force FixedRegion to the configured region so two clients
|
||||
// never end up on different regions of the same room name.
|
||||
if (!string.IsNullOrEmpty(Mod.Cfg.PhotonCloudRegion))
|
||||
{
|
||||
var fixedRegionProp = t.GetProperty("FixedRegion");
|
||||
if (fixedRegionProp is not null && fixedRegionProp.CanWrite)
|
||||
{
|
||||
fixedRegionProp.SetValue(__result, Mod.Cfg.PhotonCloudRegion);
|
||||
Mod.Log.Msg($"[photon-override] FixedRegion → {Mod.Cfg.PhotonCloudRegion}");
|
||||
}
|
||||
}
|
||||
|
||||
Mod.Log.Msg("[photon-diag] FJOLIPKKIBE built AppSettings (post-override):");
|
||||
DumpSettings(__result, t, indent: " ");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAppSettings/override failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Postfix on <c>PUNNetworkManager.EMGGKBALALH</c> — the
|
||||
/// 2020.12 watch's private AuthenticationValues builder. <c>__result</c>
|
||||
/// is the AuthValues it just built with environment + accessToken
|
||||
/// AuthGetParameters. Logs AuthType, UserId, and the full
|
||||
/// AuthGetParameters / AuthPostData payload that Photon Cloud will
|
||||
/// forward to the configured Custom Auth URL.</summary>
|
||||
public static void LogAuthValues_Postfix(object __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__result is null)
|
||||
{
|
||||
Mod.Log.Msg("[photon-diag] EMGGKBALALH returned null AuthValues (anonymous connect)");
|
||||
return;
|
||||
}
|
||||
var t = __result.GetType();
|
||||
string Read(string name) => t.GetProperty(name)?.GetValue(__result)?.ToString() ?? "<null>";
|
||||
var authType = Read("AuthType");
|
||||
var userId = Read("UserId");
|
||||
var getParams = t.GetProperty("AuthGetParameters")?.GetValue(__result)?.ToString() ?? "<null>";
|
||||
var postData = t.GetProperty("AuthPostData")?.GetValue(__result);
|
||||
var postStr = postData is byte[] bytes
|
||||
? $"<{bytes.Length} bytes: {System.Text.Encoding.UTF8.GetString(bytes)}>"
|
||||
: postData?.ToString() ?? "<null>";
|
||||
Mod.Log.Msg($"[photon-diag] EMGGKBALALH built AuthValues: AuthType={authType} UserId={userId}");
|
||||
Mod.Log.Msg($"[photon-diag] AuthGetParameters = {getParams}");
|
||||
Mod.Log.Msg($"[photon-diag] AuthPostData = {postStr}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAuthValues failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Prefix on <c>PUNNetworkManager.OnCustomAuthenticationFailed</c>
|
||||
/// — the watch's UI-error formatter only logs a truncated Unity-
|
||||
/// stringified version of the debugMessage. Capturing the raw
|
||||
/// string before the watch's handler runs gives us the FULL
|
||||
/// Photon error including the URL Photon Cloud actually tried to
|
||||
/// call (which is the smoking-gun answer to "is the dashboard URL
|
||||
/// saved or is Photon hitting a stale one").</summary>
|
||||
public static void OnCustomAuthenticationFailed_Prefix(object __0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var debugMessage = __0?.ToString() ?? "<null>";
|
||||
Mod.Log.Error("[photon-diag] OnCustomAuthenticationFailed FULL MESSAGE:");
|
||||
Mod.Log.Error(debugMessage);
|
||||
// Also dump current AppSettings — if Photon rejected the auth
|
||||
// we want to know what the watch's actual final config looked
|
||||
// like (post-override).
|
||||
var photonNetwork = FindGameType("PhotonNetwork");
|
||||
var settings = photonNetwork?.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (settings is not null)
|
||||
{
|
||||
Mod.Log.Error("[photon-diag] PhotonServerSettings at failure:");
|
||||
DumpSettings(settings, settings.GetType(), indent: " ");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] OnCustomAuthenticationFailed log failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Logs every public field + property on <paramref name="obj"/>
|
||||
/// — used by the photon-diag patches above to dump AppSettings in one
|
||||
/// pass without coupling to specific field names that differ across
|
||||
/// PUN versions (Server/Port vs AppSettings.NameServer in newer
|
||||
/// PUN 2.x, etc.).</summary>
|
||||
private static void DumpSettings(object obj, Type t, string indent)
|
||||
{
|
||||
// DeclaredOnly so we don't walk Il2CppObjectBase's properties
|
||||
// (ObjectClass, Pointer, WasCollected) — and, importantly, so
|
||||
// we don't reflect over any property whose getter internally
|
||||
// constructs a System.Uri. The Uri ctor is harmony-patched, so
|
||||
// the FIRST call to a Uri-returning getter triggers MonoMod's
|
||||
// lazy CompileMethodHook for the ctor, which fatal-CLRs
|
||||
// (0x80131506) on this build. The override has already run by
|
||||
// the time DumpSettings is called — the dump is diagnostic
|
||||
// only, so it's safer to stay scoped to user-land properties.
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
|
||||
try
|
||||
{
|
||||
foreach (var p in t.GetProperties(flags))
|
||||
{
|
||||
if (p.GetIndexParameters().Length > 0) continue;
|
||||
if (!p.CanRead) continue;
|
||||
// Defensively skip any property whose declared return
|
||||
// type is Uri (would still trigger ctor on .ToString()
|
||||
// path even if we tried to read it).
|
||||
if (p.PropertyType == typeof(Uri)) continue;
|
||||
object? v;
|
||||
try { v = p.GetValue(obj); } catch { continue; }
|
||||
if (v is Uri) continue;
|
||||
var str = v is null ? "<null>" : v.ToString();
|
||||
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
|
||||
{
|
||||
Mod.Log.Msg($"{indent}{p.Name} (nested):");
|
||||
DumpSettings(v, v.GetType(), indent + " ");
|
||||
continue;
|
||||
}
|
||||
Mod.Log.Msg($"{indent}{p.Name} = {str}");
|
||||
}
|
||||
foreach (var f in t.GetFields(flags))
|
||||
{
|
||||
if (f.FieldType == typeof(Uri)) continue;
|
||||
object? v;
|
||||
try { v = f.GetValue(obj); } catch { continue; }
|
||||
if (v is Uri) continue;
|
||||
var str = v is null ? "<null>" : v.ToString();
|
||||
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
|
||||
{
|
||||
Mod.Log.Msg($"{indent}{f.Name} (nested):");
|
||||
DumpSettings(v, v.GetType(), indent + " ");
|
||||
continue;
|
||||
}
|
||||
Mod.Log.Msg($"{indent}{f.Name} = {str}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] DumpSettings failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// GetCurrentLockToken parked alongside the injector — see
|
||||
// attic/AuthValuesInjector.cs.attic.
|
||||
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
|
||||
internal static class SavePatches
|
||||
{
|
||||
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
|
||||
// our currently-loaded room. Captured BEFORE the watch's caching
|
||||
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
|
||||
// (roomId, subRoomId) tuple — which is every post-save push for a
|
||||
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
|
||||
// would-be no-op setter call into a real transition.
|
||||
private static volatile string? s_lastPushedBlobName;
|
||||
private static long s_lastPushedRoomId;
|
||||
private static long s_lastPushedSubRoomId;
|
||||
|
||||
// Hook on Notifications.OnNotificationReceived(string json). Fires for
|
||||
// every SignalR message before any handler dispatch. Parses the JSON
|
||||
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
|
||||
public static void OnNotificationReceived_Prefix(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
// Fast reject: only parse JSON if it could plausibly be a
|
||||
// SubscriptionUpdateRoom payload. Saves cycles on the (very
|
||||
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
|
||||
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
|
||||
return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!root.TryGetProperty("Id", out var idEl) ||
|
||||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
|
||||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
|
||||
return;
|
||||
if (!root.TryGetProperty("Msg", out var msg) ||
|
||||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!msg.TryGetProperty("Room", out var room) ||
|
||||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
long roomId = 0;
|
||||
if (room.TryGetProperty("RoomId", out var ridEl) &&
|
||||
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
roomId = ridEl.GetInt64();
|
||||
if (!msg.TryGetProperty("Scenes", out var scenes) ||
|
||||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
|
||||
scenes.GetArrayLength() == 0) return;
|
||||
var first = scenes[0];
|
||||
long subRoomId = 0;
|
||||
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
|
||||
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
subRoomId = sidEl.GetInt64();
|
||||
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
|
||||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
|
||||
var blob = blobEl.GetString();
|
||||
if (string.IsNullOrEmpty(blob)) return;
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = blob;
|
||||
Mod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void OnSubscriptionUpdateRoom_Prefix(object message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message is null) return;
|
||||
var dict = message;
|
||||
|
||||
// The watch parses this dict as Dictionary<string, object>
|
||||
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
|
||||
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
|
||||
// get_Item(key) — call it via reflection.
|
||||
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (getItem is null) return;
|
||||
|
||||
// The push wire shape from server (BuildRoomDetails) is
|
||||
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
|
||||
// on the Room sub-object; the scene's blob on Scenes[0].
|
||||
object? roomObj = TryGet(dict, getItem, "Room");
|
||||
object? scenesObj = TryGet(dict, getItem, "Scenes");
|
||||
if (roomObj is null || scenesObj is null) return;
|
||||
|
||||
long roomId = 0;
|
||||
long subRoomId = 0;
|
||||
try
|
||||
{
|
||||
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (roomItem is not null)
|
||||
{
|
||||
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
|
||||
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* roomId stays 0 — we'll log it and bail later */ }
|
||||
|
||||
// Scenes is an IL2CPP List<RoomScene>. Take the first
|
||||
// (current dorm has one scene; multi-scene rooms target
|
||||
// Scenes[currentSubRoomId] specifically, but the post-save
|
||||
// push always carries the scene that was just saved).
|
||||
object? firstScene = null;
|
||||
try
|
||||
{
|
||||
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
|
||||
}
|
||||
catch { /* leave firstScene null */ }
|
||||
if (firstScene is null) return;
|
||||
|
||||
string? newBlob = null;
|
||||
try
|
||||
{
|
||||
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (sceneItem is not null)
|
||||
{
|
||||
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
|
||||
newBlob = blob?.ToString();
|
||||
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
|
||||
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* leave newBlob null */ }
|
||||
|
||||
if (string.IsNullOrEmpty(newBlob)) return;
|
||||
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = newBlob;
|
||||
Mod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object? TryGet(object dict, MethodInfo getItem, string key)
|
||||
{
|
||||
try { return getItem.Invoke(dict, new object[] { key }); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
|
||||
// path only when the new value differs from the cached field. The
|
||||
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
|
||||
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
|
||||
// save it calls the setter with the OLD blob — a no-op that skips
|
||||
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
|
||||
// pointed at the boot-time bytes, and PostSaveReloading deserializes
|
||||
// those over the live world — wiping any MakerPen object the user
|
||||
// just placed.
|
||||
//
|
||||
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
|
||||
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
|
||||
// after b__4 returns True, even though the SubscriptionUpdateRoom
|
||||
// push already carried Scenes[0].DataBlobName="v11".
|
||||
//
|
||||
// Strategy: intercept the setter. If the incoming value matches the
|
||||
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
|
||||
// newer version (the freshly-pushed one), substitute that into the
|
||||
// value parameter so the real setter sees a transition and fires
|
||||
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
|
||||
// Falls through silently when no override is needed.
|
||||
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
|
||||
{
|
||||
string currentForLog = "<unread>";
|
||||
string sceneForLog = "<unread>";
|
||||
string scenes0ForLog = "<unread>";
|
||||
try
|
||||
{
|
||||
if (__instance is null)
|
||||
{
|
||||
Mod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var rpmType = __instance.GetType();
|
||||
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (blobNameProp is null)
|
||||
{
|
||||
Mod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
|
||||
return;
|
||||
}
|
||||
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
|
||||
|
||||
var roomsType = FindGameType("RecNet.Rooms");
|
||||
if (roomsType is null)
|
||||
{
|
||||
Mod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var localScene = roomsType.GetProperty("LocalRoomScene",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localScene is not null)
|
||||
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
|
||||
|
||||
var localDetails = roomsType.GetProperty("LocalRoomDetails",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localDetails is not null)
|
||||
{
|
||||
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
|
||||
if (scenes is not null)
|
||||
{
|
||||
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
var first = indexer?.Invoke(scenes, new object[] { 0 });
|
||||
if (first is not null)
|
||||
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional trace of EVERYTHING we read so we can see in
|
||||
// the log exactly why we did or didn't override.
|
||||
Mod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
|
||||
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
|
||||
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
|
||||
|
||||
// Pick a candidate "newer" blob name. Priority order:
|
||||
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
|
||||
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
|
||||
// cache gate drops the push).
|
||||
// 2. LocalRoomScene.DataBlobName.
|
||||
// 3. Scenes[0].DataBlobName.
|
||||
string? pushedBlob = s_lastPushedBlobName;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
|
||||
|
||||
if (string.IsNullOrEmpty(pushedBlob)) return;
|
||||
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
|
||||
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
|
||||
|
||||
Mod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
|
||||
value = pushedBlob;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
|
||||
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
|
||||
}
|
||||
}
|
||||
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
|
||||
internal static class TlsPatches
|
||||
{
|
||||
// Both BouncyCastle TLS authenticators have the same `void
|
||||
// NotifyServerCertificate(...)` signature whose body throws when the
|
||||
// chain doesn't validate. Returning false from a Harmony prefix on a
|
||||
// void method skips the original — the certificate is accepted by
|
||||
// virtue of NotifyServerCertificate never throwing.
|
||||
public static bool NotifyServerCertificate_Prefix()
|
||||
{
|
||||
return !Mod.Cfg.EnableTlsTrustBypass; // false skips original; true (bypass off) lets it run
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DiagnosticPatches
|
||||
{
|
||||
public static void Write(string message)
|
||||
{
|
||||
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
|
||||
try { Mod.Log.Msg(line); } catch { }
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Mod.DiagnosticsLogPath))
|
||||
File.AppendAllText(Mod.DiagnosticsLogPath, line + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void NamedMethod_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ChatPatches
|
||||
{
|
||||
private static bool s_logged;
|
||||
|
||||
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
|
||||
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
|
||||
// before raising the in-room Photon chat RPC; if it returns false
|
||||
// the coroutine bails silently and the sender doesn't even see
|
||||
// their own message echo.
|
||||
public static void CanLocalPlayerChat_Postfix(ref bool __result)
|
||||
{
|
||||
if (!__result && !s_logged)
|
||||
{
|
||||
s_logged = true;
|
||||
Mod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
|
||||
}
|
||||
__result = true;
|
||||
}
|
||||
|
||||
// Skip the original. Returning false from a Harmony prefix stops
|
||||
// the underlying call.
|
||||
public static bool RemoveInvalidCharactersFromMessage_Prefix()
|
||||
{
|
||||
Mod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void SendChatEmote_Prefix(string message)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
|
||||
}
|
||||
|
||||
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
|
||||
}
|
||||
|
||||
public static void RpcChatEmote_Prefix(string message)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
|
||||
}
|
||||
}
|
||||
|
||||
// Log-only diagnostics for the stuck first-dorm "Change Username?" dialog.
|
||||
// See the registration site in OnLateInitializeMelon and
|
||||
// Cfg.TraceRegistrationDialog. Nothing here changes game behaviour — every
|
||||
// hook only writes to the diagnostics log.
|
||||
internal static class RegistrationPatches
|
||||
{
|
||||
private static bool s_loggedFullyReg;
|
||||
|
||||
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
|
||||
// (does NOT modify) the return value. False ⇒ the dorm raises the
|
||||
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
|
||||
// stays readable (it's polled from several call sites every boot).
|
||||
public static void IsFullyRegistered_Postfix(bool __result)
|
||||
{
|
||||
if (!s_loggedFullyReg)
|
||||
{
|
||||
s_loggedFullyReg = true;
|
||||
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
|
||||
}
|
||||
}
|
||||
|
||||
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
|
||||
// the dialog-response handler (a dialog was shown). Names reach the
|
||||
// prefix via __originalMethod so we can tell which branch ran.
|
||||
public static void PromptLambda_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
|
||||
}
|
||||
|
||||
// ConfirmUIDialog button handlers. If one of these fires when the user
|
||||
// taps a dialog button but the modal stays open, the break is in the
|
||||
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
|
||||
// routing. "Okay" on the screenshot is Button_Affirmative.
|
||||
public static bool ButtonAffirmative_Prefix(object __instance)
|
||||
{
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
|
||||
|
||||
if (!IsRegistrationWarningDialog(__instance))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryResolveAffirmativeWithoutClose(__instance))
|
||||
{
|
||||
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
|
||||
return false;
|
||||
}
|
||||
|
||||
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ButtonNegative_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
|
||||
public static void ButtonNotNow_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
|
||||
public static void ButtonCancel_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
|
||||
|
||||
private static bool IsRegistrationWarningDialog(object instance)
|
||||
{
|
||||
var title = ReadTextField(instance, "titleText");
|
||||
var body = ReadTextField(instance, "bodyText");
|
||||
|
||||
return Contains(title, "Change Username")
|
||||
&& Contains(body, "permanently lost")
|
||||
&& Contains(body, "password");
|
||||
}
|
||||
|
||||
private static bool TryResolveAffirmativeWithoutClose(object instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
var respond = AccessTools.Method(instance.GetType(), "Respond");
|
||||
if (respond is null)
|
||||
{
|
||||
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
|
||||
}
|
||||
|
||||
if (respond is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameters = respond.GetParameters();
|
||||
if (parameters.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
|
||||
respond.Invoke(instance, new[] { response, (object)false });
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static object CreateAffirmativeResponse(Type responseType)
|
||||
{
|
||||
if (responseType.IsEnum)
|
||||
{
|
||||
return Enum.ToObject(responseType, 1);
|
||||
}
|
||||
|
||||
return Convert.ChangeType(1, responseType);
|
||||
}
|
||||
|
||||
private static string? ReadTextField(object instance, string fieldName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var field = AccessTools.Field(instance.GetType(), fieldName);
|
||||
var textObject = field?.GetValue(instance);
|
||||
if (textObject is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var property = AccessTools.Property(textObject.GetType(), "text");
|
||||
if (property?.GetValue(textObject) is string text)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
var getter = AccessTools.Method(textObject.GetType(), "get_text");
|
||||
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string fragment)
|
||||
{
|
||||
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Trace hooks for the watch's room-join flow. Registered by
|
||||
// Mod.RegisterJoinTracePatches — see that method for the verified call
|
||||
// chain. Everything here is log-only; no prefix returns false, so the
|
||||
// game's own join logic runs unchanged.
|
||||
internal static class JoinPatches
|
||||
{
|
||||
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
|
||||
// "Dorm Room" button. If this never fires, the button's UnityEvent
|
||||
// binding is the problem, not the join chain.
|
||||
public static void ButtonDormRoom_Prefix()
|
||||
{
|
||||
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
|
||||
}
|
||||
|
||||
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
|
||||
// values here: Harmony's `object[] __args` forces a trampoline that
|
||||
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
|
||||
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
|
||||
// when the boot-time dorm auto-join first calls this method. Logging
|
||||
// the overload's parameter *types* (pure reflection metadata, no value
|
||||
// boxing) is enough to tell which overload fired — the entry overload
|
||||
// (String,…) is the "DormRoom" press; the others are inner legs.
|
||||
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
|
||||
}
|
||||
|
||||
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
|
||||
// resolver that feeds the join. __0 is the single first param: a room
|
||||
// NAME string (String overload) or room ID (Int64 overload). Reading
|
||||
// it via `object __0` is safe — it's a string/long, not one of the
|
||||
// obfuscated ref-types that crashed `object[] __args`. This is the
|
||||
// value we need to compare boot (works) vs button (fails).
|
||||
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
|
||||
{
|
||||
var arg = __0?.ToString() ?? "<null>";
|
||||
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
|
||||
}
|
||||
|
||||
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
|
||||
// b__165_1 is the reject handler that raises the "An error occured /
|
||||
// contact recroom.happyfox.com" toast — so if THIS name appears, the
|
||||
// join failed; the success legs (other b__*) firing means it advanced.
|
||||
// (Same __args/0x80131506 constraint as above — name only.)
|
||||
public static void JoinCallback_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||
}
|
||||
|
||||
// Dedicated capture for the reject handler b__165_1(string error) —
|
||||
// the answer to "why did the join fail". Single string param, so
|
||||
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
|
||||
// is the string that becomes the happyfox toast.
|
||||
public static void JoinError_Prefix(object __0)
|
||||
{
|
||||
var err = __0?.ToString() ?? "<null>";
|
||||
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
|
||||
}
|
||||
|
||||
// Parameter-type list from reflection metadata only — never reads
|
||||
// instance values, so it can't trigger the MonoMod trampoline crash.
|
||||
private static string Sig(MethodBase m)
|
||||
{
|
||||
var ps = m.GetParameters();
|
||||
var names = new List<string>(ps.Length);
|
||||
foreach (var p in ps) names.Add(p.ParameterType.Name);
|
||||
return $"{ps.Length} args: {string.Join(",", names)}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
// 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>";
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// AuthValuesInjector — PARKED 2026-05-28 (december-2020-12-18)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
|
||||
// doesn't pick it up. Don't rename to .cs unless you're putting the
|
||||
// feature back.
|
||||
//
|
||||
// What this used to do
|
||||
// --------------------
|
||||
// Built a Photon AuthenticationValues with `userid` + `LoginLock`
|
||||
// custom-auth parameters from the in-game RecNet account state. The
|
||||
// server's /photon/customauth controller (PhotonCustomAuthController.cs)
|
||||
// validated those parameters against the player's /player/login lock.
|
||||
//
|
||||
// Status in this branch
|
||||
// ---------------------
|
||||
// The method existed but was **never actually wired to a harmony
|
||||
// hook** in this branch's OnLateInitializeMelon. December's Photon
|
||||
// AuthValues path goes through PhotonPatches.LogAuthValues_Postfix
|
||||
// (postfix on PUNNetworkManager.EMGGKBALALH), which only logs — it
|
||||
// doesn't inject. So this code was effectively dead in december.
|
||||
//
|
||||
// Removed because:
|
||||
// 1. Photon Cloud is currently configured with Custom Authentication
|
||||
// disabled at the AppId level (anonymous connects allowed), so
|
||||
// no server-side auth probe.
|
||||
// 2. Even when the watch did send AuthValues, the upstream Cloudflare
|
||||
// / Localtunnel layer 403'd Photon's auth probe before our
|
||||
// controller could decide anything.
|
||||
//
|
||||
// How to put it back
|
||||
// ------------------
|
||||
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic suffix).
|
||||
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the LoadConfig
|
||||
// line.
|
||||
// 3. Wire the harmony hook in OnLateInitializeMelon. The march branch's
|
||||
// pick of NetworkingPeer.CallAuthenticate doesn't fire in IL2CPP, so
|
||||
// consider hooking PUNNetworkManager.EMGGKBALALH postfix instead and
|
||||
// mutating its returned AuthValues in place — that's the mangled
|
||||
// method december verifies fires (LogAuthValues_Postfix already
|
||||
// proves this entry point works).
|
||||
// 4. Put `"InjectAuthValues": true` back in dorknet-clientmod.json.template.
|
||||
// 5. If you also want the server to enforce the lock, set
|
||||
// Photon__RequireCustomAuth=true on the server.
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
namespace DorkNet.ClientMod.Attic;
|
||||
|
||||
internal static class AuthValuesInjector
|
||||
{
|
||||
public static void AuthValuesInjector_Prefix(object __instance)
|
||||
{
|
||||
if (!Mod.Cfg.InjectAuthValues) return;
|
||||
try
|
||||
{
|
||||
// Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020
|
||||
// watch never sets them itself, so we set them right before
|
||||
// NetworkingPeer.CallAuthenticate forwards to the wire op. The
|
||||
// userid is RecNet.Core.LocalAccountId, the LoginLock comes
|
||||
// from PlayerPrefs key LoginLockTokenV2.
|
||||
// RecNet.Core.LocalAccountId is declared `int` in the game
|
||||
// build (dump.cs:573622 → 0x30 backing field). Convert via
|
||||
// Convert.ToInt64 so boxing as int still gives us the long we
|
||||
// need below; a direct (long) cast on boxed int would throw.
|
||||
var coreType = FindGameType("RecNet.Core");
|
||||
var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj);
|
||||
if (accountId <= 0)
|
||||
{
|
||||
Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)");
|
||||
return;
|
||||
}
|
||||
|
||||
var photonNetwork = FindGameType("PhotonNetwork");
|
||||
var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static);
|
||||
var existing = avProp?.GetValue(null);
|
||||
if (existing is not null)
|
||||
{
|
||||
var userIdProp = existing.GetType().GetProperty("UserId");
|
||||
var existingUserId = userIdProp?.GetValue(existing) as string;
|
||||
if (!string.IsNullOrEmpty(existingUserId)) return;
|
||||
}
|
||||
|
||||
var token = GetCurrentLockToken();
|
||||
|
||||
var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues");
|
||||
if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; }
|
||||
var av = Activator.CreateInstance(authValuesType);
|
||||
var authTypeProp = authValuesType.GetProperty("AuthType");
|
||||
if (authTypeProp is not null)
|
||||
{
|
||||
var customAuthValue = authTypeProp.PropertyType.IsEnum
|
||||
? Enum.ToObject(authTypeProp.PropertyType, 0)
|
||||
: Convert.ChangeType(0, authTypeProp.PropertyType);
|
||||
authTypeProp.SetValue(av, customAuthValue);
|
||||
}
|
||||
authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString());
|
||||
var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) });
|
||||
addParam?.Invoke(av, new object[] { "userid", accountId.ToString() });
|
||||
addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty });
|
||||
|
||||
avProp?.SetValue(null, av);
|
||||
Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "<missing>" : "<set>")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static string? GetCurrentLockToken()
|
||||
{
|
||||
try
|
||||
{
|
||||
var playerPrefs = FindGameType("UnityEngine.PlayerPrefs");
|
||||
var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) });
|
||||
var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty });
|
||||
if (string.IsNullOrEmpty(raw)) return null;
|
||||
const string key = "\"LockToken\":\"";
|
||||
var ix = raw.IndexOf(key, StringComparison.Ordinal);
|
||||
if (ix < 0) return null;
|
||||
ix += key.Length;
|
||||
var end = raw.IndexOf('"', ix);
|
||||
return end < 0 ? null : raw[ix..end];
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// FindGameType in the live Mod.cs is a delegate to Mod.ResolveType.
|
||||
// Use that on restore; this is just a placeholder to keep the file
|
||||
// compilable if someone untreats it without re-checking the wiring.
|
||||
private static Type? FindGameType(string name) => null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"ServerHost": "{HOST}",
|
||||
"PhotonAppId": "{PHOTON_APPID}",
|
||||
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
|
||||
"PhotonCloudRegion": "{PHOTON_REGION}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||
"QuestMaxTeamSize": 0,
|
||||
"QuestMaxTeamSizeRooms": [
|
||||
"CrimsonCauldron",
|
||||
"Crescendo",
|
||||
"GoldenTrophy",
|
||||
"IsleOfLostSkulls",
|
||||
"TheRiseofJumbotron"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
471710
|
||||
@@ -0,0 +1,290 @@
|
||||
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 ===");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<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>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"EnableDebugConsole": false,
|
||||
"DebugConsoleToggleKey": "BackQuote",
|
||||
"DiagnoseDevMenu": false,
|
||||
"EnableDevWatchButton": false,
|
||||
"DevCommands": [
|
||||
"Help=help",
|
||||
"Fly=fly",
|
||||
"NoClip=noclip"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Login.CachedLogin — dump.cs:578388, Deserialize at RVA 0x1447BD0.
|
||||
///
|
||||
/// Verified by disassembly: reads platform, platformId, accountId,
|
||||
/// lastLoginTime (DateTime), requirePassword (default false). The Account
|
||||
/// field on the C# class is NOT read from the JSON dict — it is set via
|
||||
/// SetAccount(Account) after the fact, so the JSON shape doesn't need it.
|
||||
///
|
||||
/// Returned by:
|
||||
/// GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
|
||||
/// GET api.rec.net/api/platformlogin/cached
|
||||
/// GET accounts.rec.net/account/v1/savedlogins
|
||||
/// as a List<CachedLogin>. Empty list = no remembered accounts.
|
||||
/// </summary>
|
||||
public class CachedLogin
|
||||
{
|
||||
// PlatformManager.PlatformType (int)
|
||||
[JsonPropertyName("platform")]
|
||||
public int Platform { get; set; }
|
||||
|
||||
[JsonPropertyName("platformId")]
|
||||
public string PlatformId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("accountId")]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("lastLoginTime")]
|
||||
public DateTime LastLoginTime { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[JsonPropertyName("requirePassword")]
|
||||
public bool RequirePassword { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
public class LoginRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public long BuildTimestamp { get; set; }
|
||||
public int Platform { get; set; }
|
||||
public string? DeviceId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Login.LoginResponse — dump.cs:578473 (private nested class),
|
||||
/// Deserialize at RVA 0x1447D10.
|
||||
///
|
||||
/// Verified by disassembly: this is a standard OAuth 2.0 RFC 6749
|
||||
/// password-grant token response. The Deserialize body has two branches:
|
||||
///
|
||||
/// error = dict["error"];
|
||||
/// if (string.IsNullOrEmpty(error)) {
|
||||
/// this.AccessToken = dict["access_token"];
|
||||
/// this.RefreshToken = dict["refresh_token"];
|
||||
/// } else {
|
||||
/// this.ErrorDescription = dict["error_description"];
|
||||
/// }
|
||||
///
|
||||
/// Returned by api/platformlogin/v5 (the 2020 build's login endpoint)
|
||||
/// and api/platformlogin/refresh.
|
||||
///
|
||||
/// All four keys are snake_case, NOT PascalCase. This is the standard
|
||||
/// OAuth response format — Rec Room conforms to RFC 6749 here.
|
||||
/// </summary>
|
||||
public class LoginResponse
|
||||
{
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
[JsonPropertyName("error_description")]
|
||||
public string? ErrorDescription { get; set; }
|
||||
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
// =====================================================================
|
||||
// JSON keys verified by disassembling each .Deserialize method body
|
||||
// directly from GameAssembly.dll. Each key here is the literal string
|
||||
// passed to Util.GetKey / Util.GetKeyOrDefault inside the client. Util.GetKey
|
||||
// uses Dictionary<string,object>.TryGetValue, which is case-sensitive — so
|
||||
// the keys MUST match exactly.
|
||||
//
|
||||
// Convention summary (it's mixed):
|
||||
// RecNetResult lowercase "success", "error"
|
||||
// Account camelCase "accountId", "displayName", "isJunior"…
|
||||
// SelfAccount camelCase "email", "birthday", "juniorState"…
|
||||
// LoginResponse snake_case (OAuth) "access_token", "error_description"…
|
||||
// RefreshLoginResp. lowercase "token"
|
||||
// CachedLogin camelCase "platformId", "lastLoginTime"…
|
||||
// CreateAccountResp uses "value" as the wrapper key for the Account dict
|
||||
//
|
||||
// NOTE: TreatAsJunior on the C# side maps to JSON key "isJunior" — they
|
||||
// don't match. Account.Deserialize reads dict["isJunior"], not dict["treatAsJunior"].
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.RecNetResult — dump.cs:584325, Deserialize at RVA 0x1146340.
|
||||
/// Reads "success" (required) and "error" (optional).
|
||||
/// </summary>
|
||||
public class RecNetResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.Account — dump.cs:567684, Deserialize at RVA 0xF9DE50.
|
||||
/// JSON keys verified in the current 2020 IL2CPP build. Account.Deserialize
|
||||
/// reads profileImage.
|
||||
/// Other keys: accountId, username, displayName, isJunior, platforms. The C# Account class also has
|
||||
/// RawUsername and HasBirthday properties but Deserialize does not read
|
||||
/// them from JSON (they're set via MakeNameAdhereToPlatformRequirements
|
||||
/// or other flows). We expose them with reasonable camelCase keys for
|
||||
/// other endpoints that may consume them.
|
||||
/// </summary>
|
||||
public class RecNetAccount
|
||||
{
|
||||
private string profileImage = string.Empty;
|
||||
|
||||
[JsonPropertyName("accountId")]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileImage")]
|
||||
public string ProfileImage
|
||||
{
|
||||
get => profileImage;
|
||||
set => profileImage = value ?? string.Empty;
|
||||
}
|
||||
|
||||
// Account.Deserialize reads "isJunior" — NOT "treatAsJunior".
|
||||
// The C# property name and the JSON key name differ.
|
||||
[JsonPropertyName("isJunior")]
|
||||
public bool TreatAsJunior { get; set; } = false;
|
||||
|
||||
// PlatformManager.PlatformMask: Steam=1, Oculus=2, etc.
|
||||
[JsonPropertyName("platforms")]
|
||||
public int Platforms { get; set; } = 1;
|
||||
|
||||
// Not in Account.Deserialize body but kept for forward-compat.
|
||||
[JsonPropertyName("rawUsername")]
|
||||
public string RawUsername { get; set; } = string.Empty;
|
||||
|
||||
// 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>
|
||||
/// RecNet.SelfAccount : Account — dump.cs:567797, Deserialize at RVA 0xAF4FB0.
|
||||
/// Calls base Account.Deserialize, then reads:
|
||||
/// email, phone, birthday, juniorState, parentAccountId.
|
||||
/// </summary>
|
||||
public class RecNetSelfAccount : RecNetAccount
|
||||
{
|
||||
[JsonPropertyName("email")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("phone")]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("birthday")]
|
||||
public DateTime? Birthday { get; set; } = new DateTime(2000, 1, 1);
|
||||
|
||||
// RecNet.JuniorState enum (int)
|
||||
[JsonPropertyName("juniorState")]
|
||||
public int JuniorState { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("parentAccountId")]
|
||||
public int? ParentAccountId { get; set; } = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accounts.CreateAccountResponse : RecNetResult — dump.cs:568167,
|
||||
/// Deserialize at RVA 0xFAA200.
|
||||
/// Calls base RecNetResult.Deserialize (reads "success", "error"),
|
||||
/// then reads the Account from dict["value"] — NOT dict["account"].
|
||||
/// Verified by disassembly: the only string literal after the base
|
||||
/// call is "value", passed to Util.GetObjectKey<object>.
|
||||
/// </summary>
|
||||
public class CreateAccountResponse : RecNetResult
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public RecNetAccount Account { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Login.RefreshLoginResponse — dump.cs:578533 (private nested class),
|
||||
/// Deserialize at RVA 0x145B1E0.
|
||||
///
|
||||
/// Verified by disassembly: reads exactly one key — lowercase "token" —
|
||||
/// via Util.GetKey<string>. Single-field response from
|
||||
/// POST api/platformlogin/refresh: the new (rotated) bearer access token.
|
||||
/// </summary>
|
||||
public class RefreshLoginResponse
|
||||
{
|
||||
[JsonPropertyName("token")]
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Avatar;
|
||||
|
||||
public class AvatarData
|
||||
{
|
||||
[JsonPropertyName("AccountId")]
|
||||
public long AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("AvatarItemInstances")]
|
||||
public List<AvatarItemInstance> AvatarItemInstances { get; set; } = [];
|
||||
}
|
||||
|
||||
public class AvatarItemInstance
|
||||
{
|
||||
[JsonPropertyName("Id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("ItemId")]
|
||||
public long ItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("IsEquipped")]
|
||||
public bool IsEquipped { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Avatar;
|
||||
|
||||
public class AvatarItem
|
||||
{
|
||||
[JsonPropertyName("ItemId")]
|
||||
public long ItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("Name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Equipped")]
|
||||
public bool Equipped { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Config;
|
||||
|
||||
// Required keys per RecNet.RecRoomConfig.Deserialize disassembly (RVA 0x1146FA0).
|
||||
// Required-throw keys: ShareBaseUrl, LevelProgressionMaps[].Level, [].RequiredXp.
|
||||
// Required-object keys (GetObjectKey, accept null): ServerMaintenance,
|
||||
// AutoMicMutingConfig.
|
||||
//
|
||||
// NOTE: the level entry uses "RequiredXp" (lowercase 'p') per the IL string
|
||||
// literal — NOT "RequiredXP". A capital P here would throw KeyNotFoundException.
|
||||
public class RecRoomConfig
|
||||
{
|
||||
[JsonPropertyName("MessageOfTheDay")]
|
||||
public string MessageOfTheDay { get; set; } = "Welcome to the private server!";
|
||||
|
||||
[JsonPropertyName("CdnBaseUri")]
|
||||
public string CdnBaseUri { get; set; } = string.Empty;
|
||||
|
||||
// Required by client (string, GetKey). Used for share-link generation.
|
||||
[JsonPropertyName("ShareBaseUrl")]
|
||||
public string ShareBaseUrl { get; set; } = "https://rec.net/";
|
||||
|
||||
// GetObjectKey<ServerMaintenanceDTO>. Required key. The deserializer
|
||||
// accepts null without throwing, but the C# property getter on the
|
||||
// client doesn't null-check before use during boot. Send a real DTO
|
||||
// with StartsInMinutes far in the future = "no upcoming maintenance".
|
||||
[JsonPropertyName("ServerMaintenance")]
|
||||
public ServerMaintenanceDTO? ServerMaintenance { get; set; } = new();
|
||||
|
||||
// GetObjectKey<AutoMicMutingConfig>. Required by the client AND
|
||||
// dereferenced by AudioManager.Initialize during PostLogin boot —
|
||||
// null causes:
|
||||
// NullReferenceException at RecNet.Config.get_AutoMicMutingConfig
|
||||
// at AudioManager.Initialize
|
||||
// which kills the boot sequence with "Error initializing Core Systems".
|
||||
// Must be a real object with all 8 float fields populated.
|
||||
[JsonPropertyName("AutoMicMutingConfig")]
|
||||
public AutoMicMutingConfig AutoMicMutingConfig { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("PhotonConfig")]
|
||||
public PhotonConfig PhotonConfig { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("MatchmakingParams")]
|
||||
public MatchmakingParams MatchmakingParams { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("LevelProgressionMaps")]
|
||||
public List<LevelProgressionEntry> LevelProgressionMaps { get; set; } = LevelProgressionEntry.Default();
|
||||
|
||||
[JsonPropertyName("DailyObjectives")]
|
||||
public List<List<DailyObjective>> DailyObjectives { get; set; } = DailyObjective.DefaultSets();
|
||||
|
||||
[JsonPropertyName("ConfigTable")]
|
||||
public Dictionary<string, string> ConfigTable { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("ServiceUrls")]
|
||||
public Dictionary<string, string> ServiceUrls { get; set; } = [];
|
||||
}
|
||||
|
||||
public class PhotonConfig
|
||||
{
|
||||
[JsonPropertyName("AppId")]
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("VoiceAppId")]
|
||||
public string VoiceAppId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("CloudRegion")]
|
||||
public string CloudRegion { get; set; } = "us";
|
||||
|
||||
[JsonPropertyName("CrcCheckEnabled")]
|
||||
public bool CrcCheckEnabled { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("EnableServerTracingAfterDisconnect")]
|
||||
public bool EnableServerTracingAfterDisconnect { get; set; } = false;
|
||||
}
|
||||
|
||||
public class MatchmakingParams
|
||||
{
|
||||
[JsonPropertyName("MaxPlayersPerRoom")]
|
||||
public int MaxPlayersPerRoom { get; set; } = 8;
|
||||
|
||||
[JsonPropertyName("MinPlayersToStart")]
|
||||
public int MinPlayersToStart { get; set; } = 1;
|
||||
}
|
||||
|
||||
public class LevelProgressionEntry
|
||||
{
|
||||
[JsonPropertyName("Level")]
|
||||
public int Level { get; set; }
|
||||
|
||||
// Note the lowercase 'p' — the client's RecRoomConfig.Deserialize calls
|
||||
// Util.GetKey<int>("RequiredXp", ...). PascalCase XP would miss.
|
||||
[JsonPropertyName("RequiredXp")]
|
||||
public int RequiredXp { get; set; }
|
||||
|
||||
public static List<LevelProgressionEntry> Default()
|
||||
{
|
||||
var entries = new List<LevelProgressionEntry>();
|
||||
for (int i = 0; i <= 30; i++)
|
||||
entries.Add(new LevelProgressionEntry { Level = i, RequiredXp = i * 500 });
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
// Inner item shape for RecRoomConfig.DailyObjectives, verified by the
|
||||
// disassembled iteration body — only "type" and "score" are read. Other
|
||||
// fields the client doesn't read get ignored, so we only send these two.
|
||||
public class DailyObjective
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public int Type { get; set; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public int Score { get; set; }
|
||||
|
||||
public static List<List<DailyObjective>> DefaultSets()
|
||||
{
|
||||
var sets = new List<List<DailyObjective>>();
|
||||
for (int day = 0; day < 7; day++)
|
||||
{
|
||||
sets.Add(
|
||||
[
|
||||
new DailyObjective { Type = 1, Score = 50 },
|
||||
new DailyObjective { Type = 2, Score = 30 },
|
||||
new DailyObjective { Type = 3, Score = 20 },
|
||||
]);
|
||||
}
|
||||
return sets;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.AutoMicMutingConfig — dump.cs class, Deserialize at RVA 0xFA3AE0.
|
||||
/// All 8 float fields are required (Util.GetKey<float>). Defaults are
|
||||
/// "feature effectively disabled" — high thresholds, no force-mute.
|
||||
/// </summary>
|
||||
public class AutoMicMutingConfig
|
||||
{
|
||||
[JsonPropertyName("MicSpamVolumeThreshold")]
|
||||
public float MicSpamVolumeThreshold { get; set; } = 1.0f;
|
||||
|
||||
[JsonPropertyName("MicVolumeSampleInterval")]
|
||||
public float MicVolumeSampleInterval { get; set; } = 0.1f;
|
||||
|
||||
[JsonPropertyName("MicVolumeSampleRollingWindowLength")]
|
||||
public float MicVolumeSampleRollingWindowLength { get; set; } = 5.0f;
|
||||
|
||||
[JsonPropertyName("MicSpamSamplePercentageForWarning")]
|
||||
public float MicSpamSamplePercentageForWarning { get; set; } = 1.0f;
|
||||
|
||||
[JsonPropertyName("MicSpamSamplePercentageForWarningToEnd")]
|
||||
public float MicSpamSamplePercentageForWarningToEnd { get; set; } = 0.5f;
|
||||
|
||||
[JsonPropertyName("MicSpamSamplePercentageForForceMute")]
|
||||
public float MicSpamSamplePercentageForForceMute { get; set; } = 1.0f;
|
||||
|
||||
[JsonPropertyName("MicSpamSamplePercentageForForceMuteToEnd")]
|
||||
public float MicSpamSamplePercentageForForceMuteToEnd { get; set; } = 0.5f;
|
||||
|
||||
[JsonPropertyName("MicSpamWarningStateVolumeMultiplier")]
|
||||
public float MicSpamWarningStateVolumeMultiplier { get; set; } = 1.0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.ServerMaintenanceDTO — dump.cs, Deserialize at RVA 0xAF5450.
|
||||
/// Single required key: StartsInMinutes (int). A large positive value
|
||||
/// means "maintenance window is far away" = effectively never.
|
||||
/// </summary>
|
||||
public class ServerMaintenanceDTO
|
||||
{
|
||||
[JsonPropertyName("StartsInMinutes")]
|
||||
public int StartsInMinutes { get; set; } = int.MaxValue;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,261 @@
|
||||
version=1
|
||||
|
||||
# This file caches language service data to improve the performance of C# Dev Kit.
|
||||
# It is not intended for manual editing. It can safely be deleted and will be
|
||||
# regenerated automatically. For more information, see https://aka.ms/lscache
|
||||
#
|
||||
# To control where cache files are stored, use the following VS Code setting:
|
||||
# "dotnet.projectsystem.cacheInProjectFolder": true
|
||||
|
||||
[project]
|
||||
language=C#
|
||||
primary
|
||||
lastDtbSucceeded
|
||||
|
||||
[properties]
|
||||
AssemblyName=DorkNet.Models
|
||||
CommandLineArgsForDesignTimeEvaluation=-langversion:13.0 -define:TRACE
|
||||
CompilerGeneratedFilesOutputPath=
|
||||
MaxSupportedLangVersion=13.0
|
||||
ProjectAssetsFile=<PATH>obj/project.assets.json
|
||||
RootNamespace=DorkNet.Models
|
||||
RunAnalyzers=
|
||||
RunAnalyzersDuringLiveAnalysis=
|
||||
SolutionPath=<PATH>../DorkNet.sln
|
||||
TargetFrameworkIdentifier=.NETCoreApp
|
||||
TargetPath=<PATH>bin/Debug/net9.0/DorkNet.Models.dll
|
||||
TargetRefPath=<PATH>obj/Debug/net9.0/ref/DorkNet.Models.dll
|
||||
TemporaryDependencyNodeTargetIdentifier=net9.0
|
||||
|
||||
[commandLineArguments]
|
||||
/noconfig
|
||||
/unsafe-
|
||||
/checked-
|
||||
/nowarn:1701,1702,1701,1702
|
||||
/fullpaths
|
||||
/nostdlib+
|
||||
/errorreport:prompt
|
||||
/warn:9
|
||||
/define:TRACE;DEBUG;NET;NET9_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER
|
||||
/highentropyva+
|
||||
/nullable:enable
|
||||
/debug+
|
||||
/debug:portable
|
||||
/filealign:512
|
||||
/optimize-
|
||||
/out:obj\Debug\net9.0\DorkNet.Models.dll
|
||||
/refout:obj\Debug\net9.0\refint\DorkNet.Models.dll
|
||||
/target:library
|
||||
/warnaserror-
|
||||
/utf8output
|
||||
/deterministic+
|
||||
/langversion:13.0
|
||||
/warnaserror+:NU1605,SYSLIB0011
|
||||
|
||||
[sourceFiles]
|
||||
Auth/
|
||||
CachedLogin.cs
|
||||
LoginRequest.cs
|
||||
LoginResponse.cs
|
||||
RecNetResult.cs
|
||||
RefreshLoginResponse.cs
|
||||
Avatar/
|
||||
AvatarData.cs
|
||||
AvatarItem.cs
|
||||
Config/RecRoomConfig.cs
|
||||
GameSessions/GameSession.cs
|
||||
Notification/PushNotification.cs
|
||||
obj/Debug/net9.0/
|
||||
.NETCoreApp,Version=v9.0.AssemblyAttributes.cs
|
||||
DorkNet.Models.AssemblyInfo.cs
|
||||
DorkNet.Models.GlobalUsings.g.cs
|
||||
Players/
|
||||
ModerationBlockDetail.cs
|
||||
PlayerProfile.cs
|
||||
Progression.cs
|
||||
Reputation.cs
|
||||
UpdateProfileRequest.cs
|
||||
Relationships/Relationship.cs
|
||||
|
||||
[metadataReferences]
|
||||
<DOTNET>/packs/Microsoft.NETCore.App.Ref/9.0.16/ref/net9.0/
|
||||
Microsoft.CSharp.dll
|
||||
Microsoft.VisualBasic.Core.dll
|
||||
Microsoft.VisualBasic.dll
|
||||
Microsoft.Win32.Primitives.dll
|
||||
Microsoft.Win32.Registry.dll
|
||||
mscorlib.dll
|
||||
netstandard.dll
|
||||
System.AppContext.dll
|
||||
System.Buffers.dll
|
||||
System.Collections.Concurrent.dll
|
||||
System.Collections.dll
|
||||
System.Collections.Immutable.dll
|
||||
System.Collections.NonGeneric.dll
|
||||
System.Collections.Specialized.dll
|
||||
System.ComponentModel.Annotations.dll
|
||||
System.ComponentModel.DataAnnotations.dll
|
||||
System.ComponentModel.dll
|
||||
System.ComponentModel.EventBasedAsync.dll
|
||||
System.ComponentModel.Primitives.dll
|
||||
System.ComponentModel.TypeConverter.dll
|
||||
System.Configuration.dll
|
||||
System.Console.dll
|
||||
System.Core.dll
|
||||
System.Data.Common.dll
|
||||
System.Data.DataSetExtensions.dll
|
||||
System.Data.dll
|
||||
System.Diagnostics.Contracts.dll
|
||||
System.Diagnostics.Debug.dll
|
||||
System.Diagnostics.DiagnosticSource.dll
|
||||
System.Diagnostics.FileVersionInfo.dll
|
||||
System.Diagnostics.Process.dll
|
||||
System.Diagnostics.StackTrace.dll
|
||||
System.Diagnostics.TextWriterTraceListener.dll
|
||||
System.Diagnostics.Tools.dll
|
||||
System.Diagnostics.TraceSource.dll
|
||||
System.Diagnostics.Tracing.dll
|
||||
System.dll
|
||||
System.Drawing.dll
|
||||
System.Drawing.Primitives.dll
|
||||
System.Dynamic.Runtime.dll
|
||||
System.Formats.Asn1.dll
|
||||
System.Formats.Tar.dll
|
||||
System.Globalization.Calendars.dll
|
||||
System.Globalization.dll
|
||||
System.Globalization.Extensions.dll
|
||||
System.IO.Compression.Brotli.dll
|
||||
System.IO.Compression.dll
|
||||
System.IO.Compression.FileSystem.dll
|
||||
System.IO.Compression.ZipFile.dll
|
||||
System.IO.dll
|
||||
System.IO.FileSystem.AccessControl.dll
|
||||
System.IO.FileSystem.dll
|
||||
System.IO.FileSystem.DriveInfo.dll
|
||||
System.IO.FileSystem.Primitives.dll
|
||||
System.IO.FileSystem.Watcher.dll
|
||||
System.IO.IsolatedStorage.dll
|
||||
System.IO.MemoryMappedFiles.dll
|
||||
System.IO.Pipelines.dll
|
||||
System.IO.Pipes.AccessControl.dll
|
||||
System.IO.Pipes.dll
|
||||
System.IO.UnmanagedMemoryStream.dll
|
||||
System.Linq.dll
|
||||
System.Linq.Expressions.dll
|
||||
System.Linq.Parallel.dll
|
||||
System.Linq.Queryable.dll
|
||||
System.Memory.dll
|
||||
System.Net.dll
|
||||
System.Net.Http.dll
|
||||
System.Net.Http.Json.dll
|
||||
System.Net.HttpListener.dll
|
||||
System.Net.Mail.dll
|
||||
System.Net.NameResolution.dll
|
||||
System.Net.NetworkInformation.dll
|
||||
System.Net.Ping.dll
|
||||
System.Net.Primitives.dll
|
||||
System.Net.Quic.dll
|
||||
System.Net.Requests.dll
|
||||
System.Net.Security.dll
|
||||
System.Net.ServicePoint.dll
|
||||
System.Net.Sockets.dll
|
||||
System.Net.WebClient.dll
|
||||
System.Net.WebHeaderCollection.dll
|
||||
System.Net.WebProxy.dll
|
||||
System.Net.WebSockets.Client.dll
|
||||
System.Net.WebSockets.dll
|
||||
System.Numerics.dll
|
||||
System.Numerics.Vectors.dll
|
||||
System.ObjectModel.dll
|
||||
System.Reflection.DispatchProxy.dll
|
||||
System.Reflection.dll
|
||||
System.Reflection.Emit.dll
|
||||
System.Reflection.Emit.ILGeneration.dll
|
||||
System.Reflection.Emit.Lightweight.dll
|
||||
System.Reflection.Extensions.dll
|
||||
System.Reflection.Metadata.dll
|
||||
System.Reflection.Primitives.dll
|
||||
System.Reflection.TypeExtensions.dll
|
||||
System.Resources.Reader.dll
|
||||
System.Resources.ResourceManager.dll
|
||||
System.Resources.Writer.dll
|
||||
System.Runtime.CompilerServices.Unsafe.dll
|
||||
System.Runtime.CompilerServices.VisualC.dll
|
||||
System.Runtime.dll
|
||||
System.Runtime.Extensions.dll
|
||||
System.Runtime.Handles.dll
|
||||
System.Runtime.InteropServices.dll
|
||||
System.Runtime.InteropServices.JavaScript.dll
|
||||
System.Runtime.InteropServices.RuntimeInformation.dll
|
||||
System.Runtime.Intrinsics.dll
|
||||
System.Runtime.Loader.dll
|
||||
System.Runtime.Numerics.dll
|
||||
System.Runtime.Serialization.dll
|
||||
System.Runtime.Serialization.Formatters.dll
|
||||
System.Runtime.Serialization.Json.dll
|
||||
System.Runtime.Serialization.Primitives.dll
|
||||
System.Runtime.Serialization.Xml.dll
|
||||
System.Security.AccessControl.dll
|
||||
System.Security.Claims.dll
|
||||
System.Security.Cryptography.Algorithms.dll
|
||||
System.Security.Cryptography.Cng.dll
|
||||
System.Security.Cryptography.Csp.dll
|
||||
System.Security.Cryptography.dll
|
||||
System.Security.Cryptography.Encoding.dll
|
||||
System.Security.Cryptography.OpenSsl.dll
|
||||
System.Security.Cryptography.Primitives.dll
|
||||
System.Security.Cryptography.X509Certificates.dll
|
||||
System.Security.dll
|
||||
System.Security.Principal.dll
|
||||
System.Security.Principal.Windows.dll
|
||||
System.Security.SecureString.dll
|
||||
System.ServiceModel.Web.dll
|
||||
System.ServiceProcess.dll
|
||||
System.Text.Encoding.CodePages.dll
|
||||
System.Text.Encoding.dll
|
||||
System.Text.Encoding.Extensions.dll
|
||||
System.Text.Encodings.Web.dll
|
||||
System.Text.Json.dll
|
||||
System.Text.RegularExpressions.dll
|
||||
System.Threading.Channels.dll
|
||||
System.Threading.dll
|
||||
System.Threading.Overlapped.dll
|
||||
System.Threading.Tasks.Dataflow.dll
|
||||
System.Threading.Tasks.dll
|
||||
System.Threading.Tasks.Extensions.dll
|
||||
System.Threading.Tasks.Parallel.dll
|
||||
System.Threading.Thread.dll
|
||||
System.Threading.ThreadPool.dll
|
||||
System.Threading.Timer.dll
|
||||
System.Transactions.dll
|
||||
System.Transactions.Local.dll
|
||||
System.ValueTuple.dll
|
||||
System.Web.dll
|
||||
System.Web.HttpUtility.dll
|
||||
System.Windows.dll
|
||||
System.Xml.dll
|
||||
System.Xml.Linq.dll
|
||||
System.Xml.ReaderWriter.dll
|
||||
System.Xml.Serialization.dll
|
||||
System.Xml.XDocument.dll
|
||||
System.Xml.XmlDocument.dll
|
||||
System.Xml.XmlSerializer.dll
|
||||
System.Xml.XPath.dll
|
||||
System.Xml.XPath.XDocument.dll
|
||||
WindowsBase.dll
|
||||
|
||||
[analyzerReferences]
|
||||
<DOTNET>/packs/Microsoft.NETCore.App.Ref/9.0.16/analyzers/dotnet/cs/
|
||||
Microsoft.Interop.ComInterfaceGenerator.dll
|
||||
Microsoft.Interop.JavaScript.JSImportGenerator.dll
|
||||
Microsoft.Interop.LibraryImportGenerator.dll
|
||||
Microsoft.Interop.SourceGeneration.dll
|
||||
System.Text.Json.SourceGeneration.dll
|
||||
System.Text.RegularExpressions.Generator.dll
|
||||
<DOTNET>/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/
|
||||
Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll
|
||||
Microsoft.CodeAnalysis.NetAnalyzers.dll
|
||||
|
||||
[analyzerConfigFiles]
|
||||
<DOTNET>/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_9_default.globalconfig
|
||||
obj/Debug/net9.0/DorkNet.Models.GeneratedMSBuildEditorConfig.editorconfig
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.GameSessions;
|
||||
|
||||
public class GameSession
|
||||
{
|
||||
[JsonPropertyName("GameSessionId")]
|
||||
public long GameSessionId { get; set; }
|
||||
|
||||
[JsonPropertyName("RegionId")]
|
||||
public string RegionId { get; set; } = "us";
|
||||
|
||||
[JsonPropertyName("RoomId")]
|
||||
public string RoomId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("RoomName")]
|
||||
public string RoomName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ActivityLevelId")]
|
||||
public string ActivityLevelId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("EventId")]
|
||||
public long? EventId { get; set; }
|
||||
|
||||
[JsonPropertyName("Private")]
|
||||
public bool Private { get; set; }
|
||||
|
||||
[JsonPropertyName("GameInProgress")]
|
||||
public bool GameInProgress { get; set; }
|
||||
|
||||
[JsonPropertyName("MaxCapacity")]
|
||||
public int MaxCapacity { get; set; } = 8;
|
||||
|
||||
[JsonPropertyName("IsFull")]
|
||||
public bool IsFull { get; set; }
|
||||
|
||||
[JsonPropertyName("PlayerCount")]
|
||||
public int PlayerCount { get; set; }
|
||||
|
||||
[JsonPropertyName("PhotonRoomName")]
|
||||
public string PhotonRoomName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class JoinRandomGameSessionRequest
|
||||
{
|
||||
[JsonPropertyName("RoomId")]
|
||||
public string? RoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("ActivityLevelId")]
|
||||
public string? ActivityLevelId { get; set; }
|
||||
|
||||
[JsonPropertyName("RegionId")]
|
||||
public string RegionId { get; set; } = "us";
|
||||
}
|
||||
|
||||
public class JoinGameSessionResponse
|
||||
{
|
||||
[JsonPropertyName("Result")]
|
||||
public JoinGameErrorCode Result { get; set; } = JoinGameErrorCode.Success;
|
||||
|
||||
[JsonPropertyName("GameSession")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public GameSession? GameSession { get; set; }
|
||||
}
|
||||
|
||||
public enum JoinGameErrorCode
|
||||
{
|
||||
Success = 0,
|
||||
Full = 1,
|
||||
NotFound = 2,
|
||||
Banned = 3,
|
||||
Error = 4,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Notification;
|
||||
|
||||
public class PushNotification
|
||||
{
|
||||
[JsonPropertyName("Id")]
|
||||
public PushNotificationId Id { get; set; }
|
||||
|
||||
[JsonPropertyName("Msg")]
|
||||
public object? Msg { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the client's <c>RecNet.Notifications+PushNotificationId</c>
|
||||
/// enum exactly (decompiled at
|
||||
/// <c>Cpp2IL_CS/.../RecNet/Notifications.cs:52-89</c>). Values are
|
||||
/// non-contiguous because the client allocates them in groups (1–6
|
||||
/// core; 11–15 subscriptions; 20–25 moderation; 30–31 gifts;
|
||||
/// 60–62 storefronts; 70–71 consumables; 80–85 events; 90 chat;
|
||||
/// 95–96 community board; 100 inventions).
|
||||
///
|
||||
/// Server <c>NotifyAsync(playerId, id)</c> pushes raise an event with
|
||||
/// this int value over the SignalR hub. If the value doesn't match
|
||||
/// what the client expects for a given concept, the push is silently
|
||||
/// dropped — match the client byte-for-byte.
|
||||
/// </summary>
|
||||
public enum PushNotificationId
|
||||
{
|
||||
RelationshipChanged = 1,
|
||||
MessageReceived = 2,
|
||||
MessageDeleted = 3,
|
||||
PresenceHeartbeatResponse = 4,
|
||||
RefreshLogin = 5,
|
||||
Logout = 6,
|
||||
SubscriptionUpdateProfile = 11,
|
||||
SubscriptionUpdatePresence = 12,
|
||||
SubscriptionUpdateGameSession = 13,
|
||||
SubscriptionUpdateRoom = 15,
|
||||
ModerationQuitGame = 20,
|
||||
ModerationUpdateRequired = 21,
|
||||
ModerationKick = 22,
|
||||
ModerationKickAttemptFailed = 23,
|
||||
ModerationRoomBan = 24,
|
||||
ServerMaintenance = 25,
|
||||
GiftPackageReceived = 30,
|
||||
GiftPackageReceivedImmediate = 31,
|
||||
ProfileJuniorStatusUpdate = 40,
|
||||
RelationshipsInvalid = 50,
|
||||
StorefrontBalanceAdd = 60,
|
||||
StorefrontBalanceUpdate = 61,
|
||||
StorefrontBalancePurchase = 62,
|
||||
ConsumableMappingAdded = 70,
|
||||
ConsumableMappingRemoved = 71,
|
||||
PlayerEventCreated = 80,
|
||||
PlayerEventUpdated = 81,
|
||||
PlayerEventDeleted = 82,
|
||||
PlayerEventResponseChanged = 83,
|
||||
PlayerEventResponseDeleted = 84,
|
||||
PlayerEventStateChanged = 85,
|
||||
ChatMessageReceived = 90,
|
||||
CommunityBoardUpdate = 95,
|
||||
CommunityBoardAnnouncementUpdate = 96,
|
||||
InventionModerationStateChanged = 100,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.ModerationBlockDetail — dump.cs:583626, Deserialize at RVA 0x1450830.
|
||||
///
|
||||
/// Verified by disassembly. Required keys (Util.GetKey, throws if missing):
|
||||
/// ReportCategory (int), Duration (int), GameSessionId (long), Message (string).
|
||||
/// Optional keys (Util.GetKeyOrDefault):
|
||||
/// IsHostKick (bool, default false), IsBan (bool, default false),
|
||||
/// PlayerIdReporter (Nullable<int>, default null).
|
||||
///
|
||||
/// Returned by:
|
||||
/// GET api.rec.net/api/PlayerReporting/v1/moderationBlockDetails
|
||||
/// (Single object, NOT a list — the client method GetModerationBlockDetails
|
||||
/// returns IPromise<ModerationBlockDetail>.)
|
||||
///
|
||||
/// To indicate "user is not blocked": ReportCategory=0, Duration=0,
|
||||
/// GameSessionId=0, Message="", IsBan=false, IsHostKick=false,
|
||||
/// PlayerIdReporter=null.
|
||||
/// </summary>
|
||||
public class ModerationBlockDetail
|
||||
{
|
||||
// PlayerReporting.ReportCategory enum (int)
|
||||
[JsonPropertyName("ReportCategory")]
|
||||
public int ReportCategory { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("Duration")]
|
||||
public int Duration { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("GameSessionId")]
|
||||
public long GameSessionId { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("Message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("IsHostKick")]
|
||||
public bool IsHostKick { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("PlayerIdReporter")]
|
||||
public int? PlayerIdReporter { get; set; } = null;
|
||||
|
||||
[JsonPropertyName("IsBan")]
|
||||
public bool IsBan { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
public class PlayerProfile
|
||||
{
|
||||
private string? profileImage;
|
||||
|
||||
[JsonPropertyName("AccountId")]
|
||||
public long AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("Username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("DisplayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ProfileImage")]
|
||||
public string? ProfileImage
|
||||
{
|
||||
get => profileImage;
|
||||
set => profileImage = value;
|
||||
}
|
||||
|
||||
[JsonPropertyName("ProfileImageName")]
|
||||
public string? ProfileImageName
|
||||
{
|
||||
get => profileImage;
|
||||
set => profileImage = value;
|
||||
}
|
||||
|
||||
[JsonPropertyName("BannerImage")]
|
||||
public string? BannerImage { get; set; }
|
||||
|
||||
[JsonPropertyName("IsJunior")]
|
||||
public bool IsJunior { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("Level")]
|
||||
public int Level { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("XP")]
|
||||
public int XP { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("Reputation")]
|
||||
public int Reputation { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("Bio")]
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("IsVerified")]
|
||||
public bool IsVerified { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("IsDeveloper")]
|
||||
public bool IsDeveloper { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("IsCommunityTeam")]
|
||||
public bool IsCommunityTeam { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("CanReceiveInvites")]
|
||||
public bool CanReceiveInvites { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.Progression — dump.cs:582273, Deserialize at RVA 0x1142BB0.
|
||||
///
|
||||
/// Verified by disassembly: reads "PlayerId", "Level", "XP" via
|
||||
/// Util.GetKey<int>. All three keys are required (non-default) — missing
|
||||
/// any of them throws KeyNotFoundException at the client.
|
||||
///
|
||||
/// Note: the JSON uses "PlayerId" not "AccountId" even though the C# property
|
||||
/// is AccountId on most other types. Returned by:
|
||||
/// GET api.rec.net/api/players/v1/progression/{accountId}
|
||||
/// </summary>
|
||||
public class Progression
|
||||
{
|
||||
[JsonPropertyName("PlayerId")]
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
[JsonPropertyName("Level")]
|
||||
public int Level { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("XP")]
|
||||
public int XP { get; set; } = 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.Reputation — dump.cs:584087, Deserialize at RVA 0x114A830.
|
||||
///
|
||||
/// Verified by disassembly. ALL Cheer*/Subscriber* keys are required
|
||||
/// (Util.GetKey<int>), missing any throws KeyNotFoundException.
|
||||
/// "SelectedCheer" is the only optional one (Util.GetKeyOrDefault<Nullable<int>>).
|
||||
///
|
||||
/// CRITICAL TYPO: the JSON key is "Noteriety" — the dev mistyped "Notoriety".
|
||||
/// The C# property is named Notoriety in dump.cs (line 584117) but the dict
|
||||
/// lookup uses "Noteriety". Server response MUST match the typo.
|
||||
///
|
||||
/// Returned by:
|
||||
/// GET api.rec.net/api/playerReputation/v1/{accountId}
|
||||
/// </summary>
|
||||
public class Reputation
|
||||
{
|
||||
[JsonPropertyName("AccountId")]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
// sic: see class XML doc — typo in the binary's Util.GetKey call.
|
||||
[JsonPropertyName("Noteriety")]
|
||||
public float Notoriety { get; set; } = 0f;
|
||||
|
||||
[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; }
|
||||
|
||||
// PlayerCheering.CheerCategory enum (int) — Nullable; null means "no
|
||||
// active cheer set". This is the only optional key.
|
||||
[JsonPropertyName("SelectedCheer")]
|
||||
public int? SelectedCheer { get; set; } = null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
public class UpdateProfileRequest
|
||||
{
|
||||
[JsonPropertyName("DisplayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[JsonPropertyName("Bio")]
|
||||
public string? Bio { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Relationships;
|
||||
|
||||
public class RelationshipData
|
||||
{
|
||||
[JsonPropertyName("AccountId")]
|
||||
public long AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("OtherAccountId")]
|
||||
public long OtherAccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("Status")]
|
||||
public RelationshipStatus Status { get; set; }
|
||||
|
||||
[JsonPropertyName("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum RelationshipStatus
|
||||
{
|
||||
None = 0,
|
||||
Friend = 1,
|
||||
PendingSent = 2,
|
||||
PendingReceived = 3,
|
||||
Blocked = 4,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Action / controller filter that 403s any request whose
|
||||
/// JWT-resolved player doesn't have <c>IsAdmin == true</c>. Does not
|
||||
/// itself authenticate — pair it with <c>[Authorize]</c> to ensure a
|
||||
/// valid bearer is present before this runs.
|
||||
///
|
||||
/// Usage:
|
||||
/// <code>
|
||||
/// [Authorize]
|
||||
/// [AdminOnly]
|
||||
/// [Route("api/admin/v1")]
|
||||
/// public class AdminController { ... }
|
||||
/// </code>
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
|
||||
public class AdminOnlyAttribute : Attribute, IAsyncAuthorizationFilter
|
||||
{
|
||||
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
|
||||
{
|
||||
// AuthorizationFilterContext.HttpContext is always populated and
|
||||
// exposes the same ClaimsPrincipal a controller would see, so
|
||||
// there's no reason to round-trip through IHttpContextAccessor
|
||||
// (which isn't even registered in this app's DI container — the
|
||||
// earlier lookup crashed every admin request with
|
||||
// "No service for type IHttpContextAccessor has been registered").
|
||||
if (ControllerBaseExtensions.CurrentPlayerId(context.HttpContext.User) is not long id)
|
||||
{
|
||||
// No JWT → defer to [Authorize]'s 401.
|
||||
context.Result = new UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
|
||||
var db = context.HttpContext.RequestServices
|
||||
.GetRequiredService<DorkNetDbContext>();
|
||||
var isAdmin = await db.Players
|
||||
.Where(p => p.Id == id)
|
||||
.Select(p => p.IsAdmin)
|
||||
.FirstOrDefaultAsync();
|
||||
if (!isAdmin)
|
||||
context.Result = new ForbidResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Short-circuits any authenticated request whose JWT-resolved player has
|
||||
/// a non-null <c>BannedUntil</c> in the future. Sits AFTER
|
||||
/// <c>UseAuthentication</c>/<c>UseAuthorization</c> in the pipeline so the
|
||||
/// claims principal is populated before we look at it.
|
||||
///
|
||||
/// Anonymous requests (no/invalid bearer) pass through unchanged — this
|
||||
/// middleware never short-circuits them. Whether they're allowed is the
|
||||
/// per-endpoint <c>[Authorize]</c> decision, not ours.
|
||||
///
|
||||
/// Lookup is one query per request against the Players table. Cached for
|
||||
/// the request lifetime via the scoped DbContext, so repeated bearer
|
||||
/// checks within one request hit the EF first-level cache.
|
||||
/// </summary>
|
||||
public class BanCheckMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext ctx, DorkNetDbContext db)
|
||||
{
|
||||
// Cheap early-out for anonymous traffic — no DB hit.
|
||||
var sub = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(sub, out var playerId))
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Project just the BannedUntil column — no need to materialise the
|
||||
// full player entity for every request.
|
||||
var bannedUntil = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.BannedUntil)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (bannedUntil.HasValue && bannedUntil.Value > DateTime.UtcNow)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
ctx.Response.ContentType = "application/json";
|
||||
await ctx.Response.WriteAsync(JsonSerializer.Serialize(new
|
||||
{
|
||||
Error = "banned",
|
||||
Until = bannedUntil.Value.ToString("o"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await next(ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Single canonical place for "who is the bearer-resolved player on this
|
||||
/// request." Replaces the per-controller copy-paste of:
|
||||
/// <code>
|
||||
/// private long CurrentPlayerId =>
|
||||
/// long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
/// </code>
|
||||
/// which historically existed in 10 controllers and silently NPE-bombed
|
||||
/// the action when the bearer was missing instead of returning 401.
|
||||
/// </summary>
|
||||
public static class ControllerBaseExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the bearer-resolved player id, or null when the caller is
|
||||
/// anonymous / the bearer is malformed. Use on optional-auth endpoints.
|
||||
/// </summary>
|
||||
public static long? CurrentPlayerId(this ControllerBase controller) =>
|
||||
CurrentPlayerId(controller.User);
|
||||
|
||||
/// <summary>Overload for non-controller callers (filters,
|
||||
/// middleware) that hold a <see cref="ClaimsPrincipal"/> directly.</summary>
|
||||
public static long? CurrentPlayerId(ClaimsPrincipal user)
|
||||
{
|
||||
var claim = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return long.TryParse(claim, out var id) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the bearer-resolved player id; throws
|
||||
/// <see cref="UnauthorizedAccessException"/> when missing. Use on
|
||||
/// <c>[Authorize]</c>-decorated endpoints where a missing id is a
|
||||
/// programming error (the framework should have already 401'd).
|
||||
/// </summary>
|
||||
public static long RequireCurrentPlayerId(this ControllerBase controller) =>
|
||||
controller.CurrentPlayerId()
|
||||
?? throw new UnauthorizedAccessException(
|
||||
"Bearer token claim 'NameIdentifier' is missing or non-numeric. " +
|
||||
"If this fired on an [Authorize] endpoint, JWT validation upstream " +
|
||||
"is misconfigured.");
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Short-circuits any incoming request whose remote IP falls under an
|
||||
/// active <see cref="DorkNet.Server.Data.Entities.IpBanEntity"/> row
|
||||
/// with 403. Sits BEFORE authentication so banned IPs can't spend a
|
||||
/// round-trip negotiating a JWT — the connection terminates as early
|
||||
/// as possible.
|
||||
///
|
||||
/// Lookup is one query against IpBans per request. The table is small
|
||||
/// (a few rows in practice) and the simple "Cidr matches?" filter is
|
||||
/// done in C# rather than SQL because CIDR maths in SQLite is awkward.
|
||||
/// </summary>
|
||||
public class IpBanCheckMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext ctx, DorkNetDbContext db)
|
||||
{
|
||||
var remote = ctx.Connection.RemoteIpAddress;
|
||||
if (remote is null)
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Materialise the active-ban list once per request. Could be
|
||||
// cached for ~30s with a hosted service if it ever becomes a
|
||||
// hot path, but right now this is unmeasurable.
|
||||
var bans = await db.IpBans
|
||||
.Where(b => b.Until == null || b.Until > DateTime.UtcNow)
|
||||
.Select(b => b.Cidr)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cidr in bans)
|
||||
{
|
||||
if (CidrMatches(cidr, remote))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await ctx.Response.WriteAsync("ip_banned");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next(ctx);
|
||||
}
|
||||
|
||||
/// <summary>Match a single IP against a CIDR-or-bare-IP string.
|
||||
/// Bare IPs match exactly; CIDRs match if the prefix bits agree.
|
||||
/// Falls back to false on any parse error.</summary>
|
||||
private static bool CidrMatches(string cidr, IPAddress remote)
|
||||
{
|
||||
try
|
||||
{
|
||||
var slash = cidr.IndexOf('/');
|
||||
if (slash < 0)
|
||||
{
|
||||
return IPAddress.TryParse(cidr, out var single) &&
|
||||
single.Equals(remote);
|
||||
}
|
||||
|
||||
var addrPart = cidr[..slash];
|
||||
var prefixPart = cidr[(slash + 1)..];
|
||||
if (!IPAddress.TryParse(addrPart, out var net) ||
|
||||
!int.TryParse(prefixPart, out var prefix))
|
||||
return false;
|
||||
|
||||
// Only IPv4 networks for now — IPv6 CIDR matching is
|
||||
// straightforward but we'd need to mask 128-bit values
|
||||
// and the user's setup is single-host IPv4 anyway.
|
||||
if (net.AddressFamily != AddressFamily.InterNetwork ||
|
||||
remote.AddressFamily != AddressFamily.InterNetwork)
|
||||
return false;
|
||||
|
||||
var netBytes = net.GetAddressBytes();
|
||||
var remoteBytes = remote.GetAddressBytes();
|
||||
int fullBytes = prefix / 8;
|
||||
int extraBits = prefix % 8;
|
||||
|
||||
for (int i = 0; i < fullBytes; i++)
|
||||
if (netBytes[i] != remoteBytes[i]) return false;
|
||||
if (extraBits == 0) return true;
|
||||
|
||||
int mask = 0xFF << (8 - extraBits) & 0xFF;
|
||||
return (netBytes[fullBytes] & mask) == (remoteBytes[fullBytes] & mask);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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,239 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Account;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/account/* — same endpoints as accounts.rec.net but routed
|
||||
/// through the API service. The Accounts class in dump.cs doesn't expose its
|
||||
/// base URL constant so the game may use either host; we cover both.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AccountApiController(
|
||||
PlayerService playerService,
|
||||
AuthService authService,
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
ServerSettingsService settings,
|
||||
SignupCodeService signupCodes,
|
||||
ILogger<AccountApiController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/account/v1/create")]
|
||||
[HttpPost("api/account/v1")]
|
||||
[HttpPost("api/account/v2/create")]
|
||||
[HttpPost("api/createaccount")]
|
||||
public async Task<IActionResult> CreateAccount(
|
||||
[FromForm] int platform,
|
||||
[FromForm] string? platformId,
|
||||
[FromForm] string? deviceId,
|
||||
[FromForm] string? Username,
|
||||
[FromForm] string? DisplayName)
|
||||
{
|
||||
if (await settings.AreSignupsDisabledAsync())
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[api-account] api/account/v1/create refused — signups disabled (device={Device} platform={Platform})",
|
||||
deviceId, platform);
|
||||
await signupCodes.RecordPendingDeviceAsync(
|
||||
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
|
||||
return Ok(new CreateAccountResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Account creation is currently disabled by the server admin.",
|
||||
});
|
||||
}
|
||||
|
||||
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: deviceId,
|
||||
platform: platform,
|
||||
platformId: platformId,
|
||||
displayName: DisplayName ?? Username);
|
||||
|
||||
return Ok(new CreateAccountResponse
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/account/v1/me")]
|
||||
[HttpGet("api/account/v2/me")]
|
||||
public async Task<IActionResult> GetMe()
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(new RecNetSelfAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = string.Empty,
|
||||
Birthday = new DateTime(2000, 1, 1),
|
||||
JuniorState = 0,
|
||||
ParentAccountId = null,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the player tied to the bearer token on the request. Returns
|
||||
/// null when no/invalid bearer is supplied — caller decides whether
|
||||
/// that's a 401 or a softer fallback. Anonymous-account creation was
|
||||
/// previously the fallback path; deleted in Phase 1c because every
|
||||
/// caller has gone through `/account/create` (deviceId-bound) before
|
||||
/// reaching us.
|
||||
/// </summary>
|
||||
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
}
|
||||
|
||||
// Modification endpoints — POSTs land on api.* host with form body
|
||||
// carrying the new value. AuthAccountController on auth.* handles
|
||||
// the same shape with real persistence; we mirror that here so
|
||||
// either host works.
|
||||
|
||||
[HttpPost("api/account/v1/displayname")]
|
||||
public async Task<IActionResult> SetDisplayName([FromForm(Name = "displayName")] string? displayName,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
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 });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/bio")]
|
||||
public async Task<IActionResult> SetBio([FromForm(Name = "bio")] string? bio,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
await playerService.UpdateBioAsync(p.Id, (bio ?? value ?? string.Empty));
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/username")]
|
||||
public async Task<IActionResult> SetUsername([FromForm(Name = "username")] string? username,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
var newName = (username ?? value ?? string.Empty).Trim();
|
||||
if (newName.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
|
||||
var taken = await db.Players.AnyAsync(x => x.Username == newName && x.Id != p.Id);
|
||||
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 });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/birthday")]
|
||||
public async Task<IActionResult> SetBirthday([FromForm(Name = "birthday")] string? birthday,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
var raw = birthday ?? value ?? string.Empty;
|
||||
if (DateTime.TryParse(raw, out var parsed))
|
||||
{
|
||||
p.Birthday = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/email")]
|
||||
public async Task<IActionResult> SetEmail([FromForm(Name = "email")] string? email,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
var addr = (email ?? value ?? string.Empty).Trim();
|
||||
if (addr.Length > 0) p.Email = addr;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/phone")]
|
||||
[HttpPost("api/account/v1/confirmphone")]
|
||||
public async Task<IActionResult> SetPhone([FromForm(Name = "phone")] string? phone,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
var num = (phone ?? value ?? string.Empty).Trim();
|
||||
if (num.Length > 0) p.Phone = num;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/profileimage")]
|
||||
[HttpPost("api/account/me/profileimage")]
|
||||
[HttpPost("account/me/profileimage")]
|
||||
[HttpPost("profileimage")]
|
||||
[HttpPut("api/account/v1/profileimage")]
|
||||
[HttpPut("api/account/me/profileimage")]
|
||||
[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,
|
||||
[FromForm(Name = "fileName")] string? fileName,
|
||||
[FromForm(Name = "image")] string? imageAlt)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
|
||||
var name = imageName ?? fileName ?? imageAlt
|
||||
?? Request.Query["imageName"].ToString()
|
||||
?? Request.Query["fileName"].ToString();
|
||||
player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"[api-account] profile image set: player={PlayerId} -> {Name}",
|
||||
player.Id,
|
||||
player.ProfileImageName ?? "<cleared>");
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpGet("api/account/v1/namegen")]
|
||||
[HttpGet("api/account/v1/namegeneration")]
|
||||
public IActionResult NameGen() => Ok(new
|
||||
{
|
||||
Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" },
|
||||
Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Activities;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/activities/* — per-activity content
|
||||
/// (charades words, party-game prompts, etc.). The 2020 watch
|
||||
/// fetches these as a static list at room-join time; we ship an
|
||||
/// empty list so the activity's bundled defaults kick in.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ActivitiesController : ControllerBase
|
||||
{
|
||||
[HttpGet("api/activities/charades/v1/words")]
|
||||
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Announcements;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/announcement/v{1,2}/get — fetches the
|
||||
/// current server announcement (shown on the watch's home tab). The
|
||||
/// payload is sourced from <see cref="CommunityBoardService"/> so the
|
||||
/// admin SPA's "edit announcement" form is the single place to
|
||||
/// update what players see.
|
||||
///
|
||||
/// Wire shape per <c>RecNet/AnnouncementDTO.cs</c> — AnnouncementId,
|
||||
/// AnnouncementType, LinkType, Platform are all required via
|
||||
/// <c>Util.GetKey</c> (throws on miss); Title/Body/ImageName/LinkName/
|
||||
/// LinkUri/CreatedAt are GetKeyOrDefault.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AnnouncementsController(CommunityBoardService communityBoard) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/announcement/v1/get")]
|
||||
[HttpGet("api/announcement/v2/get")]
|
||||
public async Task<IActionResult> Current()
|
||||
{
|
||||
var board = await communityBoard.GetAsync();
|
||||
var ann = board.CurrentAnnouncement;
|
||||
if (ann is null) return Ok(Array.Empty<object>());
|
||||
return Ok(new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
AnnouncementId = 1L,
|
||||
AnnouncementType = 0,
|
||||
Title = "Server Announcement",
|
||||
Body = ann.Message ?? string.Empty,
|
||||
ImageName = "",
|
||||
LinkType = 0,
|
||||
LinkName = "",
|
||||
LinkUri = ann.MoreInfoUrl ?? string.Empty,
|
||||
Platform = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Photon custom-auth webhook (api.rec.net).
|
||||
///
|
||||
/// Configure in Photon dashboard:
|
||||
/// Authentication → Custom → URL = https://api.rec.net/api/auth/v1/photon
|
||||
/// Query params sent by client: token=<our_jwt>
|
||||
///
|
||||
/// Photon ResultCode: 1 = success, 2 = fail, 3 = param error
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PhotonAuthController(AuthService authService, PlayerService playerService) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/auth/v1/photon")]
|
||||
[HttpPost("api/auth/v1/photon")]
|
||||
public async Task<IActionResult> Authenticate([FromQuery] string? token, [FromQuery] string? Token)
|
||||
{
|
||||
var jwt = token ?? Token;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jwt))
|
||||
return Ok(new { ResultCode = 3, Message = "Missing token parameter" });
|
||||
|
||||
var playerId = authService.ValidateToken(jwt);
|
||||
if (playerId is null)
|
||||
return Ok(new { ResultCode = 2, Message = "Invalid or expired token" });
|
||||
|
||||
var player = await playerService.GetByIdAsync(playerId.Value);
|
||||
if (player is null)
|
||||
return Ok(new { ResultCode = 2, Message = "Player not found" });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ResultCode = 1,
|
||||
Message = "ok",
|
||||
UserId = player.Id.ToString(),
|
||||
Nickname = player.DisplayName ?? player.Username,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v2/gifts — wardrobe gift inbox. Mirrors
|
||||
/// the client's <c>RecNet.Avatars.DowloadGiftPackages</c> +
|
||||
/// <c>LocalRequestGiftPackage</c> + <c>LocalConsumeGiftPackage</c>
|
||||
/// flow. Wire DTO matches <c>RecNet.Avatars+GiftPackage</c>
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/Avatars.cs:192-555</c>) field-for-field
|
||||
/// so the watch's deserialiser doesn't crash.
|
||||
///
|
||||
/// Server semantics:
|
||||
/// - <c>GET v2/gifts</c> returns unconsumed gifts for caller.
|
||||
/// - <c>POST v2/gifts/generate</c> rolls a random unlocked-avatar
|
||||
/// item and stamps a <see cref="GiftPackageEntity"/> for caller —
|
||||
/// used by drop tiles and "free daily" prompts.
|
||||
/// - <c>POST v2/gifts/consume/{id}</c> marks consumed and appends
|
||||
/// the <see cref="GiftPackageEntity.AvatarItemDescOrHairDyeDesc"/>
|
||||
/// to the player's <see cref="AvatarEntity.InventoryJson"/> so it
|
||||
/// shows up in their backpack on next load.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AvatarGiftsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
LevelService level) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/avatar/v2/gifts")]
|
||||
[HttpGet("api/avatar/v3/gifts")]
|
||||
[HttpGet("api/avatar/v4/gifts")]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var rows = await db.GiftPackages
|
||||
.Where(g => g.RecipientPlayerId == pid && !g.Consumed)
|
||||
.OrderByDescending(g => g.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record GenerateGiftRequest(int? GiftContext);
|
||||
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — server picks
|
||||
/// a random unlocked-avatar item and queues a gift package. The
|
||||
/// 2020 client posts to this when the user clicks a free-gift
|
||||
/// tile or hits a context the client maps to <c>GiftContext</c>.</summary>
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
// Pick a random wardrobe StoreItem (Slug starts with
|
||||
// "wardrobe-" — that's the convention StoreService uses for
|
||||
// catalogue rows derived from outfits_assets bundles).
|
||||
var pool = await db.StoreItems
|
||||
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
|
||||
.Select(s => new { s.Slug, s.DisplayName })
|
||||
.ToListAsync();
|
||||
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
|
||||
|
||||
var rng = new Random();
|
||||
var pick = pool[rng.Next(pool.Count)];
|
||||
var guid = pick.Slug["wardrobe-".Length..];
|
||||
|
||||
// Bare GUIDs crash AvatarItem.FromRecNetString — append `,,,`
|
||||
// so the watch's parse-and-iterate doesn't blow up in
|
||||
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
|
||||
// for the canonical wire-format note.
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
FromPlayerId = null,
|
||||
AvatarItemType = 0, // Outfit
|
||||
AvatarItemDescOrHairDyeDesc = guid + ",,,",
|
||||
CurrencyType = 0, // Invalid (no currency in this gift)
|
||||
GiftContext = req?.GiftContext ?? 0,
|
||||
GiftRarity = RollRarity(rng),
|
||||
Message = $"Enjoy your new {pick.DisplayName}!",
|
||||
Platform = -1,
|
||||
PackageVariant = "Standard",
|
||||
PackageMaterial = string.Empty,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push so the watch shows the new-gift toast.
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.GiftPackageReceived, ToWire(gift));
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
|
||||
public Task<IActionResult> ConsumeViaPath(long id) => ConsumeImpl(id);
|
||||
|
||||
/// <summary>POST <c>/api/avatar/v2/gifts/consume/</c> (note trailing
|
||||
/// slash, no id in path). The watch's <c>LocalConsumeGiftPackage</c>
|
||||
/// (verified at <c>Cpp2IL_ISIL/.../RecNet/Avatars_NestedType__LocalConsumeGiftPackage_d__19.txt:276-301</c>)
|
||||
/// posts to this exact URL with a form-urlencoded body containing
|
||||
/// <c>Id</c> and <c>UnlockedLevel</c> fields. The previous
|
||||
/// implementation took <c>[FromBody] ConsumeGiftRequest</c> which
|
||||
/// only accepts <c>application/json</c> — the watch's form-encoded
|
||||
/// body got 415 Unsupported Media Type
|
||||
/// (<c>output_log.txt:'Failed to consume gift … HTTP Error 415'</c>),
|
||||
/// which left the gift unconsumed → the avatar item never landed
|
||||
/// in the player's inventory → mirror-unequip rendered a missing
|
||||
/// item and crashed.
|
||||
///
|
||||
/// Accept both JSON and form-urlencoded by reading the raw body
|
||||
/// and pulling <c>Id</c> from whichever container we got.</summary>
|
||||
[HttpPost("api/avatar/v2/gifts/consume")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data", "text/plain")]
|
||||
public async Task<IActionResult> ConsumeViaBody()
|
||||
{
|
||||
long id = 0;
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var raw = form["Id"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(raw)) raw = form["id"].ToString();
|
||||
long.TryParse(raw, out id);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var reader = new System.IO.StreamReader(Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("Id", out var v) ||
|
||||
doc.RootElement.TryGetProperty("id", out v))
|
||||
{
|
||||
if (v.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
v.TryGetInt64(out id);
|
||||
else if (v.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
long.TryParse(v.GetString(), out id);
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { /* fall through; id stays 0 */ }
|
||||
}
|
||||
}
|
||||
if (id == 0)
|
||||
{
|
||||
var raw = Request.Query["Id"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["id"].ToString();
|
||||
long.TryParse(raw, out id);
|
||||
}
|
||||
if (id == 0) return BadRequest("missing Id");
|
||||
return await ConsumeImpl(id);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ConsumeImpl(long id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id);
|
||||
if (gift is null) return NotFound();
|
||||
if (gift.RecipientPlayerId != pid) return Forbid();
|
||||
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
|
||||
|
||||
// Append avatar item to player's inventory list (JSON list of
|
||||
// GUID strings). Defensive parse — corrupted JSON resets to
|
||||
// empty list rather than 500.
|
||||
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
|
||||
{
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
{
|
||||
avatar = new AvatarEntity { PlayerId = pid };
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
try
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer
|
||||
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc))
|
||||
{
|
||||
list.Add(gift.AvatarItemDescOrHairDyeDesc);
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new[] { gift.AvatarItemDescOrHairDyeDesc });
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gift.EquipmentPrefabName) ||
|
||||
!string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid))
|
||||
{
|
||||
var itemSlug = !string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid)
|
||||
? StoreService.EquipmentSkinSlug(gift.EquipmentPrefabName, gift.EquipmentModificationGuid)
|
||||
: gift.EquipmentPrefabName;
|
||||
if (!string.IsNullOrWhiteSpace(itemSlug))
|
||||
{
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == itemSlug);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerInventory.Add(new PlayerInventoryEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
ItemSlug = itemSlug,
|
||||
Quantity = 1,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Quantity = Math.Max(row.Quantity, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply currency / XP / level rewards bundled with the gift.
|
||||
// The previous implementation only handled the avatar item path,
|
||||
// so token gifts from the admin SPA marked themselves consumed
|
||||
// but never moved the player's wallet — the player tapped the
|
||||
// gift, the box played its animation, and the balance stayed
|
||||
// unchanged. CurrencyType is the canonical RecNet enum
|
||||
// (1 = Tokens, 2 = Coins; 0 means "no currency in this gift").
|
||||
if (gift.CurrencyType > 0 && gift.Currency > 0)
|
||||
{
|
||||
await level.GrantCurrencyAsync(pid, gift.CurrencyType, gift.Currency,
|
||||
$"gift_consumed:{gift.Id}");
|
||||
}
|
||||
if (gift.Xp > 0)
|
||||
{
|
||||
await level.AwardXpAsync(pid, gift.Xp, $"gift_consumed:{gift.Id}");
|
||||
}
|
||||
// Direct level grants bypass XP curves — same pattern
|
||||
// /api/admin/v1/players/{id}/levelup uses. We don't surface
|
||||
// that as a separate helper; bump the column inline.
|
||||
if (gift.Level > 0)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is not null) player.Level += gift.Level;
|
||||
}
|
||||
|
||||
gift.Consumed = true;
|
||||
gift.ConsumedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Profile-update push so the watch's wallet readout refreshes
|
||||
// without waiting for the next /balance poll. Mirrors what
|
||||
// /api/admin/v1/players/{id}/gift does after the inbox push so
|
||||
// the toast number animates up the moment the box closes.
|
||||
if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0)
|
||||
{
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level });
|
||||
}
|
||||
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
/// <summary>Weighted rarity roll — Common 65, Uncommon 25,
|
||||
/// Rare 8, Epic 1.8, Legendary 0.2 percent.</summary>
|
||||
private static int RollRarity(Random rng) => rng.NextDouble() switch
|
||||
{
|
||||
< 0.65 => 0, // Common
|
||||
< 0.90 => 10, // Uncommon
|
||||
< 0.98 => 20, // Rare
|
||||
< 0.998 => 30, // Epic
|
||||
_ => 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
|
||||
{
|
||||
g.Id,
|
||||
g.FromPlayerId,
|
||||
g.ConsumableItemDesc,
|
||||
g.AvatarItemType,
|
||||
AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc,
|
||||
g.AvatarItemDescOrHairDyeDesc,
|
||||
g.EquipmentPrefabName,
|
||||
g.EquipmentModificationGuid,
|
||||
g.CurrencyType,
|
||||
g.Currency,
|
||||
g.Xp,
|
||||
g.Level,
|
||||
g.GiftContext,
|
||||
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,
|
||||
g.IsValid,
|
||||
g.ErrorMessage,
|
||||
g.SupportsCurrentPlatform,
|
||||
g.IsGifted,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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.Avatar.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v2 — older Avatar settings endpoint used by
|
||||
/// the 2020 client (the V3 endpoint is for newer builds and uses a
|
||||
/// different shape entirely).
|
||||
///
|
||||
/// Wire shape verified by disassembling RecNet.Avatar.Deserialize at
|
||||
/// RVA 0xFA3C30. Required keys (Util.GetKey<string>): OutfitSelections,
|
||||
/// HairColor, SkinColor, FaceFeatures. Empty strings satisfy the
|
||||
/// deserializer; the client interprets them as "use defaults" and falls
|
||||
/// back to its built-in starter avatar.
|
||||
///
|
||||
/// This implementation persists each set/get to <see cref="AvatarEntity"/>
|
||||
/// instead of stubbing — round-trip across sessions, multi-user-safe
|
||||
/// (one row per player keyed by PlayerId).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/avatar/v2")]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetAvatar()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/avatar/v2/set — replace the player's avatar settings.
|
||||
///
|
||||
/// CRITICAL: do NOT use <c>[FromBody] AvatarV2Dto</c> here. The
|
||||
/// watch's <c>RecNet.Avatar.Serialize()</c> emits each of
|
||||
/// OutfitSelections / HairColor / SkinColor / FaceFeatures as
|
||||
/// dictionary values whose runtime types are determined by the
|
||||
/// IRecNetObject serializer it uses — FaceFeatures in particular
|
||||
/// sometimes ships as a nested JSON OBJECT instead of a JSON
|
||||
/// string, and the watch may also omit fields entirely when
|
||||
/// they're empty.
|
||||
///
|
||||
/// System.Text.Json's strict type binder responds to either of those
|
||||
/// with a 400 (model state invalid → ApiController auto-400) and
|
||||
/// the watch then retries every frame, accumulating "Failed to
|
||||
/// upload Rec Room player avatar changes: HTTP Error 400" errors
|
||||
/// (output_log.txt:852+, 1000+ repeats) until the player object
|
||||
/// gets destroyed.
|
||||
///
|
||||
/// Parse the body manually as JsonElement so we accept any shape
|
||||
/// the watch sends: string-or-object for FaceFeatures (re-encode
|
||||
/// the object as a string before persisting), missing fields (keep
|
||||
/// existing values), null values (treat as empty).
|
||||
/// </summary>
|
||||
[HttpPost("api/avatar/v2")]
|
||||
[HttpPost("api/avatar/v2/set")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "text/plain")]
|
||||
public async Task<ActionResult<AvatarV2Dto>> SetAvatar()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
|
||||
// Read the raw body so we control parsing — bypasses the
|
||||
// model binder's strict type matching that was causing 400s.
|
||||
string raw;
|
||||
using (var reader = new System.IO.StreamReader(Request.Body))
|
||||
raw = await reader.ReadToEndAsync();
|
||||
|
||||
string? outfitSelections = null;
|
||||
string? hairColor = null;
|
||||
string? skinColor = null;
|
||||
string? faceFeatures = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
outfitSelections = ReadStringField(root, "OutfitSelections");
|
||||
hairColor = ReadStringField(root, "HairColor");
|
||||
skinColor = ReadStringField(root, "SkinColor");
|
||||
faceFeatures = ReadStringField(root, "FaceFeatures");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// Fall through with all-null updates if the body isn't
|
||||
// JSON. Log so we can see what the watch actually sends.
|
||||
logger.LogWarning(
|
||||
"[avatar-set] could not parse body as JSON ({Msg}); raw={Raw}",
|
||||
ex.Message, raw.Length > 500 ? raw.Substring(0, 500) + "…" : raw);
|
||||
}
|
||||
}
|
||||
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
|
||||
if (a is null)
|
||||
{
|
||||
a = new AvatarEntity { PlayerId = pid };
|
||||
db.Avatars.Add(a);
|
||||
}
|
||||
// Only overwrite fields the watch actually sent; missing keys
|
||||
// keep the previously-stored value so a partial PATCH-style
|
||||
// update doesn't accidentally wipe HairColor/SkinColor/etc.
|
||||
if (outfitSelections is not null) a.OutfitSelections = outfitSelections;
|
||||
if (hairColor is not null) a.HairColor = hairColor;
|
||||
if (skinColor is not null) a.SkinColor = skinColor;
|
||||
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
|
||||
a.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>Read a field that should be a string but may arrive
|
||||
/// as <c>null</c>, a JSON object/array (re-encoded back to a JSON
|
||||
/// string), or be missing entirely (return null → caller keeps
|
||||
/// the existing DB value).</summary>
|
||||
private static string? ReadStringField(JsonElement root, string name)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var v)) return null;
|
||||
return v.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => v.GetString() ?? string.Empty,
|
||||
JsonValueKind.Null => string.Empty,
|
||||
JsonValueKind.Object => v.GetRawText(),
|
||||
JsonValueKind.Array => v.GetRawText(),
|
||||
JsonValueKind.Number => v.GetRawText(),
|
||||
JsonValueKind.True or JsonValueKind.False => v.GetRawText(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Public lookup of another player's equipped avatar — the
|
||||
/// client uses this to render remote players' avatars in shared
|
||||
/// rooms. No auth requirement on read so anonymous calls in the
|
||||
/// boot path don't 401.</summary>
|
||||
[HttpGet("api/avatar/v2/equipped/{playerId:long}")]
|
||||
[HttpGet("api/avatar/v3/equipped/{playerId:long}")]
|
||||
[HttpGet("api/avatar/v4/equipped/{playerId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetEquippedFor(long playerId)
|
||||
{
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>Bare <c>api/avatar/v{2,3}/equipped</c> returns the
|
||||
/// caller's own equipped avatar. Without the bare path the watch's
|
||||
/// "what am I wearing" query falls through to the catch-all and
|
||||
/// gets <c>[]</c>.</summary>
|
||||
[HttpGet("api/avatar/v2/equipped")]
|
||||
[HttpGet("api/avatar/v3/equipped")]
|
||||
[HttpGet("api/avatar/v4/equipped")]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetEquippedSelf()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
private static AvatarV2Dto ToDto(AvatarEntity? a) => new()
|
||||
{
|
||||
OutfitSelections = a?.OutfitSelections ?? string.Empty,
|
||||
FaceFeatures = a?.FaceFeatures ?? string.Empty,
|
||||
HairColor = a?.HairColor ?? string.Empty,
|
||||
SkinColor = a?.SkinColor ?? string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
public class AvatarV2Dto
|
||||
{
|
||||
[JsonPropertyName("OutfitSelections")]
|
||||
public string OutfitSelections { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("HairColor")]
|
||||
public string HairColor { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("SkinColor")]
|
||||
public string SkinColor { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("FaceFeatures")]
|
||||
public string FaceFeatures { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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.Avatar.V3;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
|
||||
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
|
||||
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
|
||||
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
|
||||
///
|
||||
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
|
||||
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
|
||||
/// Slot. Caps at 20 entries per player to keep the column under
|
||||
/// the SQLite text-blob comfort zone.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private const int MaxSlots = 20;
|
||||
|
||||
[HttpGet("api/avatar/v2/saved")]
|
||||
[HttpGet("api/avatar/v3/saved")]
|
||||
[HttpGet("api/avatar/v4/saved")]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
var list = ParseList(avatar?.SavedOutfitsJson);
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
||||
/// Body is the SavedOutfit shape directly.</summary>
|
||||
[HttpPost("api/avatar/v3/saved/set")]
|
||||
[HttpPost("api/avatar/v2/saved/set")]
|
||||
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
|
||||
{
|
||||
if (body is null) return BadRequest("missing body");
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
{
|
||||
avatar = new AvatarEntity { PlayerId = pid };
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
var list = ParseList(avatar.SavedOutfitsJson);
|
||||
var idx = list.FindIndex(o => o.Slot == body.Slot);
|
||||
if (idx >= 0) list[idx] = body;
|
||||
else
|
||||
{
|
||||
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
|
||||
list.Add(body);
|
||||
}
|
||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(body);
|
||||
}
|
||||
|
||||
[HttpDelete("api/avatar/v3/saved/{slot:int}")]
|
||||
[HttpDelete("api/avatar/v2/saved/{slot:int}")]
|
||||
public async Task<IActionResult> Delete(int slot)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null) return NotFound();
|
||||
var list = ParseList(avatar.SavedOutfitsJson);
|
||||
var removed = list.RemoveAll(o => o.Slot == slot);
|
||||
if (removed == 0) return NotFound();
|
||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private static List<SavedOutfitDto> ParseList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SavedOutfitDto
|
||||
{
|
||||
[JsonPropertyName("Slot")] public int Slot { get; set; }
|
||||
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
|
||||
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
|
||||
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
|
||||
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the
|
||||
/// caller has UNLOCKED. The watch's
|
||||
/// <c>RecNet.Avatars+<DowloadUnlockedAvatarItems></c> coroutine
|
||||
/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149
|
||||
/// hits <c>"{api/avatar/}v4/items"</c>) parses the result as
|
||||
/// <c>List<UnlockedAvatarItem></c>; the wardrobe UI then
|
||||
/// renders a "buy" button for any item NOT in this list and an
|
||||
/// "equip" affordance for items that are.
|
||||
///
|
||||
/// Wire shape per
|
||||
/// Cpp2IL_ISIL/.../Avatars_NestedType_UnlockedAvatarItem.txt:186-212:
|
||||
/// "AvatarItemType" (int — Outfit=0 / HairDye=1, NOT slot-based),
|
||||
/// "AvatarItemDesc" (string — the item GUID with three empty
|
||||
/// customizations appended, see below),
|
||||
/// "PlatformMask" (int), "FriendlyName" (string),
|
||||
/// "Tooltip" (string), "Rarity" (int).
|
||||
///
|
||||
/// IMPORTANT — <c>AvatarItemDesc</c> format: the client passes this
|
||||
/// string to <c>AvatarItem.FromRecNetString(string)</c>
|
||||
/// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257).
|
||||
/// The desc is <c>String.Split(',')</c> into <c>guid + customization[0..3]</c>
|
||||
/// — the inner overload then indexes <c>customization[0]</c>,
|
||||
/// <c>customization[1]</c>, <c>customization[2]</c> and throws
|
||||
/// <c>ArgumentException</c> at <c>ArraySegment.get_Current</c> if the
|
||||
/// segment is shorter than 3. Every desc we ship MUST therefore have
|
||||
/// at least 4 comma-separated parts, so we suffix bare GUIDs with
|
||||
/// <c>",,,"</c> (three empty customizations).
|
||||
///
|
||||
/// CATALOG SOURCE — <c>data/avatar_item_lookup.json</c>. Generated by
|
||||
/// the manual/CI-only BepInEx patch under
|
||||
/// <c>tools/AvatarCatalogDumpPatch</c>. That patch reads the watch's bundled
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// SerializedDictionary at boot and writes every (guid, AvatarItemData)
|
||||
/// pair to JSON. THIS is the authoritative safe list — any GUID NOT
|
||||
/// in this file throws <c>InvalidOperationException("Missing data
|
||||
/// for guid …")</c> inside the watch's wardrobe parser, which aborts
|
||||
/// the entire coroutine on the first bad item and leaves the drawer
|
||||
/// empty. Items extracted from <c>outfits_assets_*.bundle</c>
|
||||
/// MonoBehaviours (the older <c>data/wardrobe_items.json</c>) are
|
||||
/// MASK + SWATCH asset GUIDs, NOT AvatarItem GUIDs, so that file
|
||||
/// can't be used as a safelist.
|
||||
///
|
||||
/// PER-PLAYER ownership: the watch intersects each store tile's
|
||||
/// <c>AvatarItemDesc</c> against THIS list to flip the buy-button
|
||||
/// to "Already Owned." Returning the global wardrobe here therefore
|
||||
/// makes every store item appear owned. We instead intersect the
|
||||
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
|
||||
/// catalog so the watch sees only items the player actually owns.
|
||||
/// Anonymous calls return [].
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
|
||||
{
|
||||
/// <summary>Master catalog keyed by GUID. Loaded once on first
|
||||
/// request from <c>data/avatar_item_lookup.json</c>. Same
|
||||
/// lazy-load idiom as before but the catalog itself is now a
|
||||
/// dump of the watch's bundled
|
||||
/// <c>avatarItemDataLookup</c> instead of the asset-bundle
|
||||
/// MonoBehaviour scan in the previous wardrobe_items.json.</summary>
|
||||
private static readonly Lazy<CatalogData> _catalog = new(LoadCatalog);
|
||||
|
||||
[HttpGet("api/avatar/v2/items")]
|
||||
[HttpGet("api/avatar/v3/items")]
|
||||
[HttpGet("api/avatar/v4/items")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<UnlockedAvatarItemDto>>> Get()
|
||||
{
|
||||
// Keep this switch as a fast kill-switch, but default it on now that
|
||||
// the response is bounded below. The risky path is not the parser
|
||||
// itself; it is feeding the 2020 watch too many BrowsableAvatarItem /
|
||||
// DynamicAvatarItemImposter rows at once.
|
||||
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 pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
var avatar = await db.Avatars
|
||||
.Where(a => a.PlayerId == me)
|
||||
.Select(a => new { a.InventoryJson, a.OutfitSelections })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var owned = ParseInventoryEntries(avatar?.InventoryJson);
|
||||
var ownedHairDyeSlugs = await db.PlayerInventory
|
||||
.Where(i => i.PlayerId == me && i.Quantity > 0)
|
||||
.Select(i => i.ItemSlug)
|
||||
.ToListAsync();
|
||||
var ownedHairDyes = ownedHairDyeSlugs
|
||||
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
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);
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
|
||||
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
|
||||
perSlot.TryGetValue(slot, out var count);
|
||||
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) 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,
|
||||
});
|
||||
if (result.Count >= maxItems) break;
|
||||
}
|
||||
|
||||
foreach (var slug in ownedHairDyes)
|
||||
{
|
||||
if (result.Count >= maxItems) break;
|
||||
if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue;
|
||||
StoreService.TryGetHairDyeName(slug, out var friendlyName);
|
||||
result.Add(new UnlockedAvatarItemDto
|
||||
{
|
||||
AvatarItemType = AvatarItemType.HairDye,
|
||||
AvatarItemDesc = hairColorGuid,
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = friendlyName,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
});
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The set of GUIDs the bundled
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// contains. Exposed so other controllers (StoreService seed,
|
||||
/// PlayerService starter inventory) can validate their seed
|
||||
/// data against the same safe list.</summary>
|
||||
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
|
||||
|
||||
/// <summary>Master catalog of all renderable items, keyed by
|
||||
/// GUID. Used by callers that need item metadata (slot type,
|
||||
/// name, hair-color flags) outside of the per-player ownership
|
||||
/// check.</summary>
|
||||
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
|
||||
/// (a few per slot).</summary>
|
||||
public static IReadOnlyDictionary<int, List<string>> BySlot
|
||||
=> _catalog.Value.BySlot;
|
||||
|
||||
public static bool TryGetOutfitType(string guid, out int outfitType) =>
|
||||
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
|
||||
|
||||
private IEnumerable<string> RequestedAvatarItemGuids()
|
||||
{
|
||||
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>();
|
||||
try
|
||||
{
|
||||
var strings = JsonSerializer.Deserialize<List<string>>(json);
|
||||
if (strings is not null) raw.AddRange(strings);
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (raw.Count == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
const string wardrobePrefix = "wardrobe-";
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var result = new List<OwnedEntry>();
|
||||
foreach (var entry in raw)
|
||||
{
|
||||
var value = entry;
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
|
||||
value = value[wardrobePrefix.Length..];
|
||||
var comma = value.IndexOf(',');
|
||||
var baseGuid = comma >= 0 ? value[..comma] : value;
|
||||
if (string.IsNullOrWhiteSpace(baseGuid)) continue;
|
||||
if (!seen.Add(value)) continue;
|
||||
result.Add(new OwnedEntry(baseGuid, value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Bare-GUID view of the owned set, for callers that only need
|
||||
/// ownership membership (e.g. the locked-items diff), not the swatch.</summary>
|
||||
private static List<string> ParseInventory(string? json) =>
|
||||
ParseInventoryEntries(json)
|
||||
.Select(e => e.BaseGuid)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Ensure a stored desc carries the ≥4 comma-separated parts
|
||||
/// (guid + 3 customizations) that the watch's
|
||||
/// <c>AvatarItem.FromRecNetString</c> requires — anything shorter throws
|
||||
/// inside the wardrobe parser. Stored colored descs are already
|
||||
/// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy
|
||||
/// value.</summary>
|
||||
private static string ToWatchDesc(string storedDesc)
|
||||
{
|
||||
var commas = storedDesc.Count(c => c == ',');
|
||||
return commas >= 3 ? storedDesc : storedDesc + new string(',', 3 - commas);
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(outfitSelections)) return result;
|
||||
|
||||
foreach (var selection in outfitSelections.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var comma = selection.IndexOf(',');
|
||||
var guid = comma >= 0 ? selection[..comma] : selection;
|
||||
if (!string.IsNullOrWhiteSpace(guid))
|
||||
result.Add(guid.Trim());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record CatalogData(
|
||||
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
|
||||
IReadOnlySet<string> SafeGuids,
|
||||
IReadOnlyDictionary<int, List<string>> BySlot,
|
||||
IReadOnlyDictionary<string, int> SlotByGuid);
|
||||
|
||||
private static CatalogData LoadCatalog()
|
||||
{
|
||||
// Try both lowercase 'data/' (csproj None+Link target) and
|
||||
// uppercase 'Data/' (git-tracked source folder) so case-sensitivity
|
||||
// mismatches between Windows (where the dev wrote the path) and
|
||||
// Linux (where Coolify builds the container) can't lose the lookup
|
||||
// file. Without this fallback, a single missed file → empty
|
||||
// SafeGuids → StoreService.SeedAsync skips its stale-row cleanup
|
||||
// → 418 swatch-GUID rows ship live → every store tile renders as
|
||||
// Hat_Angler.
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "avatar_item_lookup.json"),
|
||||
Path.Combine(AppContext.BaseDirectory, "Data", "avatar_item_lookup.json"),
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "data", "avatar_item_lookup.json"),
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "Data", "avatar_item_lookup.json"),
|
||||
};
|
||||
var path = candidates.FirstOrDefault(System.IO.File.Exists);
|
||||
Console.WriteLine(path is null
|
||||
? $"[avatar-catalog] avatar_item_lookup.json not found in any of: {string.Join(", ", candidates)}"
|
||||
: $"[avatar-catalog] loading from {path}");
|
||||
if (path is null) return new CatalogData(
|
||||
new Dictionary<string, UnlockedAvatarItemDto>(),
|
||||
new HashSet<string>(),
|
||||
new Dictionary<int, List<string>>(),
|
||||
new Dictionary<string, int>());
|
||||
|
||||
using var fs = System.IO.File.OpenRead(path);
|
||||
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new LookupFile();
|
||||
|
||||
var items = new Dictionary<string, UnlockedAvatarItemDto>(dump.Items.Count, StringComparer.OrdinalIgnoreCase);
|
||||
var safe = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var bySlot = new Dictionary<int, List<string>>();
|
||||
var slotByGuid = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var row in dump.Items)
|
||||
{
|
||||
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);
|
||||
items[row.Guid] = new UnlockedAvatarItemDto
|
||||
{
|
||||
// AvatarItemType is the wire enum the watch parses
|
||||
// (Outfit=0 / HairDye=1), not the slot. Rows in
|
||||
// avatarItemDataLookup are wearable avatar items, including
|
||||
// hair meshes that *use* hair color. They must still be sent
|
||||
// as Outfit; sending HairDye makes the client treat the item
|
||||
// GUID as a color GUID and log "Can not find hair dye color".
|
||||
AvatarItemType = AvatarItemType.Outfit,
|
||||
AvatarItemDesc = row.Guid + ",,,",
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = pretty,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
};
|
||||
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
||||
bySlot[row.OutfitType] = lst = new List<string>();
|
||||
lst.Add(row.Guid);
|
||||
slotByGuid[row.Guid] = row.OutfitType;
|
||||
}
|
||||
|
||||
return new CatalogData(items, safe, bySlot, slotByGuid);
|
||||
}
|
||||
|
||||
private static string PrettyName(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return string.Empty;
|
||||
// Strip leading/trailing parens that the bundled config wraps
|
||||
// names in, then turn underscores into spaces. Keep it simple —
|
||||
// the watch shows whatever string we send.
|
||||
var trimmed = raw.Trim().TrimStart('(').TrimEnd(')');
|
||||
// Drop common asset-name suffixes. Items in the runtime config
|
||||
// use the raw asset name (e.g. "Hat_Wizard", "shirt_sherlock");
|
||||
// the watch never normalises these, so we do it server-side.
|
||||
var s = trimmed.Replace('_', ' ').Trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Shape of <c>data/avatar_item_lookup.json</c> as written
|
||||
/// by <c>tools/AvatarCatalogDumpPatch</c>.</summary>
|
||||
private sealed class LookupFile
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public List<LookupItem> Items { get; set; } = new();
|
||||
|
||||
// Auxiliary GUID lists (prefab/mask/swatch/decal). Not used
|
||||
// server-side yet — they'd matter for resolving asset
|
||||
// references the watch might subsequently request, but the
|
||||
// wire protocol only ever ships the AvatarItem GUID.
|
||||
[JsonPropertyName("prefab_guids")]
|
||||
public List<string> PrefabGuids { get; set; } = new();
|
||||
[JsonPropertyName("mask_guids")]
|
||||
public List<string> MaskGuids { get; set; } = new();
|
||||
[JsonPropertyName("swatch_guids")]
|
||||
public List<string> SwatchGuids { get; set; } = new();
|
||||
[JsonPropertyName("decal_guids")]
|
||||
public List<string> DecalGuids { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class LookupItem
|
||||
{
|
||||
[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; }
|
||||
}
|
||||
|
||||
/// <summary>Mirrors <c>RecNet.Avatars+AvatarItemType</c>
|
||||
/// (Avatars.cs:186-190).</summary>
|
||||
private static class AvatarItemType
|
||||
{
|
||||
public const int Outfit = 0;
|
||||
public const int HairDye = 1;
|
||||
}
|
||||
|
||||
/// <summary>OutfitType slot enum mirrored from
|
||||
/// <c>RecRoom.Avatar.Data.Shared.OutfitType</c>. Used by other
|
||||
/// callers (PlayerService, StoreService) for slot-aware seeding.</summary>
|
||||
public static class Slot
|
||||
{
|
||||
public const int Hat = 0;
|
||||
public const int BackHead = 1;
|
||||
public const int Hair = 2;
|
||||
public const int Eye = 10;
|
||||
public const int Mouth = 20;
|
||||
public const int Neck = 100;
|
||||
public const int Shirt = 101;
|
||||
public const int Belt = 102;
|
||||
public const int Pocket = 103;
|
||||
public const int TeamShirt = 104;
|
||||
public const int Wrist = 200;
|
||||
public const int Glove = 201;
|
||||
public const int Watch = 202;
|
||||
public const int TeamWrist = 203;
|
||||
}
|
||||
}
|
||||
|
||||
public class UnlockedAvatarItemDto
|
||||
{
|
||||
[JsonPropertyName("AvatarItemType")]
|
||||
public int AvatarItemType { get; set; }
|
||||
|
||||
[JsonPropertyName("AvatarItemDesc")]
|
||||
public string AvatarItemDesc { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("PlatformMask")]
|
||||
public int PlatformMask { get; set; }
|
||||
|
||||
[JsonPropertyName("FriendlyName")]
|
||||
public string FriendlyName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Tooltip")]
|
||||
public string Tooltip { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Rarity")]
|
||||
public int Rarity { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.BugReporting;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/bugreporting/* — in-game "Report a Bug" UI.
|
||||
/// Wire request DTO matches <c>RecNet.BugReporting+BugReportDTO</c>
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/BugReporting.cs</c>): <c>Summary,
|
||||
/// Description, TestCaseKey, BuildVersion, BuildTimestamp,
|
||||
/// BundleVersionCode</c>.
|
||||
///
|
||||
/// Plus <c>POST /api/bugreporting/v1/submit</c> (legacy v1 path) and
|
||||
/// <c>POST /api/bugreporting/v2/submit</c> (renamed v2). The
|
||||
/// <c>ReportBug</c> client API also includes screenshot + log byte
|
||||
/// arrays as multipart parts; we accept-and-discard those (no
|
||||
/// storage flow yet) but persist the text fields.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class BugReportsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
public sealed class BugReportRequest
|
||||
{
|
||||
public string? Summary { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? TestCaseKey { get; set; }
|
||||
public string? BuildVersion { get; set; }
|
||||
public long? BuildTimestamp { get; set; }
|
||||
public int? BundleVersionCode { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/bugreporting/v1/submit")]
|
||||
[HttpPost("api/bugreporting/v2/submit")]
|
||||
[HttpPost("api/bugreporting/v1/reportbug")]
|
||||
[HttpPost("api/bugreporting/v2/reportbug")]
|
||||
[Consumes("application/json")]
|
||||
public async Task<IActionResult> SubmitJson([FromBody] BugReportRequest req)
|
||||
=> await PersistAsync(req);
|
||||
|
||||
/// <summary>Multipart form variant — the watch posts screenshot
|
||||
/// + log as separate parts. We pull the text-only fields and
|
||||
/// drop the binary attachments.</summary>
|
||||
[HttpPost("api/bugreporting/v1/submit-multipart")]
|
||||
[HttpPost("api/bugreporting/v2/reportbug-multipart")]
|
||||
[Consumes("multipart/form-data")]
|
||||
public async Task<IActionResult> SubmitMultipart(
|
||||
[FromForm(Name = "Summary")] string? summary,
|
||||
[FromForm(Name = "Description")] string? description,
|
||||
[FromForm(Name = "TestCaseKey")] string? testCaseKey,
|
||||
[FromForm(Name = "BuildVersion")] string? buildVersion,
|
||||
[FromForm(Name = "BuildTimestamp")] long? buildTimestamp)
|
||||
=> await PersistAsync(new BugReportRequest
|
||||
{
|
||||
Summary = summary,
|
||||
Description = description,
|
||||
TestCaseKey = testCaseKey,
|
||||
BuildVersion = buildVersion,
|
||||
BuildTimestamp = buildTimestamp,
|
||||
});
|
||||
|
||||
private async Task<IActionResult> PersistAsync(BugReportRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var summary = (req.Summary ?? string.Empty).Trim();
|
||||
var body = (req.Description ?? string.Empty).Trim();
|
||||
if (summary.Length == 0 && body.Length == 0)
|
||||
return BadRequest(new { Error = "summary or description required" });
|
||||
|
||||
db.BugReports.Add(new BugReportEntity
|
||||
{
|
||||
ReporterPlayerId = pid,
|
||||
Title = summary[..Math.Min(128, summary.Length)],
|
||||
Body = body[..Math.Min(4000, body.Length)],
|
||||
ClientVersion = req.BuildVersion ?? string.Empty,
|
||||
Platform = HttpContext.Request.Headers.UserAgent.ToString()[..Math.Min(32, HttpContext.Request.Headers.UserAgent.ToString().Length)],
|
||||
Category = string.IsNullOrEmpty(req.TestCaseKey) ? "bug" : $"bug:{req.TestCaseKey}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Submitted = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Cards;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/cards/v1/all — home-screen card panel
|
||||
/// (announcements, promos, daily-bonus tiles). Returns global cards
|
||||
/// PLUS any cards targeted at the caller, sorted by Priority then
|
||||
/// CreatedAt. Expired cards filtered out server-side.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class CardsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/cards/v1/all")]
|
||||
public async Task<IActionResult> All()
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
var now = DateTime.UtcNow;
|
||||
var rows = await db.Cards
|
||||
.Where(c => (c.PlayerId == null || c.PlayerId == pid) &&
|
||||
(c.ExpiresAt == null || c.ExpiresAt > now))
|
||||
.OrderByDescending(c => c.Priority).ThenByDescending(c => c.CreatedAt)
|
||||
.Take(50)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Title,
|
||||
c.Body,
|
||||
c.ImageName,
|
||||
c.ActionUrl,
|
||||
c.Category,
|
||||
c.CreatedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.CommunityBoard;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/communityboard/v1/current — backs the dorm's
|
||||
/// community board. The watch's
|
||||
/// <c>RecNet.CommunityBoard.GetCurrent</c> hits
|
||||
/// <c>"{api/communityboard/}v1/current"</c>
|
||||
/// (Cpp2IL_ISIL/.../CommunityBoard.txt:902) and parses the response as
|
||||
/// a <c>CommunityBoardDTO</c> with FeaturedPlayer / FeaturedRoomGroup /
|
||||
/// CurrentAnnouncement / InstagramImages / Videos
|
||||
/// (CommunityBoard_NestedType_CommunityBoardDTO.txt:460-480).
|
||||
///
|
||||
/// State lives in <see cref="CommunityBoardService"/> as a single
|
||||
/// DB row keyed at <c>CommunityBoardRows.Id = 1</c>. Admins update it
|
||||
/// via <c>POST /api/admin/v1/communityboard</c> from the admin UI.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class CommunityBoardController(
|
||||
CommunityBoardService board,
|
||||
DorkNetDbContext db,
|
||||
DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/communityboard/v1/current")]
|
||||
[HttpGet("/api/communityboard/v2/current")]
|
||||
public async Task<IActionResult> Current()
|
||||
{
|
||||
var state = await board.GetAsync();
|
||||
|
||||
// InstagramImage URL backfill — the watch's
|
||||
// InstagramImageDTO.Deserialize calls a string-typed GetKey for
|
||||
// both ImageName AND ImageUrl
|
||||
// (CommunityBoard_NestedType_InstagramImageDTO.txt:85-91); an
|
||||
// empty URL yields a broken tile. The admin SPA persists only
|
||||
// ImageName, so we derive the URL on read against the configured
|
||||
// deployment apex (DORKNET_DOMAIN).
|
||||
var instagram = state.InstagramImages.Select(img => new
|
||||
{
|
||||
ImageName = img.ImageName,
|
||||
ImageUrl = string.IsNullOrEmpty(img.ImageUrl) && !string.IsNullOrEmpty(img.ImageName)
|
||||
? $"https://{domain.Sub("img")}/{img.ImageName}"
|
||||
: img.ImageUrl,
|
||||
}).ToArray();
|
||||
|
||||
// FeaturedRoomGroup expansion — the watch's FeaturedRoomGroupDTO
|
||||
// (Rooms_NestedType_FeaturedRoomGroupDTO.txt:75-93) reads
|
||||
// FeaturedRooms as <c>List<FeaturedRoom></c> where each
|
||||
// FeaturedRoom is <c>{RoomName, RoomId, ImageName}</c> with
|
||||
// RoomId required (Util.GetKey at FeaturedRoom.txt:415-417).
|
||||
// The admin SPA persists the list as a bare <c>List<long></c>
|
||||
// of room ids — which the watch can't deserialize, throwing
|
||||
// KeyNotFoundException for "RoomId" on each item. With the list
|
||||
// throwing, GetCurrentCommunityBoardData fails and the whole
|
||||
// community-board panel renders empty. Expand the ids to full
|
||||
// FeaturedRoom shapes here so the wire payload deserialises.
|
||||
object? featuredRoomGroup = null;
|
||||
if (state.FeaturedRoomGroup is { } group)
|
||||
{
|
||||
var ids = group.FeaturedRooms.ToList();
|
||||
var rooms = ids.Count == 0
|
||||
? new()
|
||||
: await db.Rooms
|
||||
.Where(r => ids.Contains(r.Id))
|
||||
.Select(r => new { r.Id, r.Name, r.ImageName })
|
||||
.ToDictionaryAsync(r => r.Id);
|
||||
// 2020.12 FeaturedRoomGroup deserializer (NMPFCIJPODA.PPGFHEDFBEA)
|
||||
// reads required keys: FeaturedRoomGroupId, Name, Rooms (NOT
|
||||
// FeaturedRooms). Missing/renamed → KeyNotFoundException →
|
||||
// breaks the boot promise chain on the community-board fetch.
|
||||
featuredRoomGroup = new
|
||||
{
|
||||
FeaturedRoomGroupId = 0L,
|
||||
Name = group.Name ?? string.Empty,
|
||||
Rooms = ids.Select(id => rooms.TryGetValue(id, out var r)
|
||||
? new { RoomName = r.Name, RoomId = r.Id, ImageName = 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 })
|
||||
.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
FeaturedPlayer = state.FeaturedPlayer,
|
||||
FeaturedRoomGroup = featuredRoomGroup,
|
||||
CurrentAnnouncement = state.CurrentAnnouncement,
|
||||
InstagramImages = instagram,
|
||||
Videos = state.Videos,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Config.V1;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v1")]
|
||||
public class ConfigController(ConfigService configService) : ControllerBase
|
||||
{
|
||||
[HttpGet("motd")]
|
||||
public ActionResult<string> GetMotd()
|
||||
{
|
||||
var cfg = configService.GetConfig($"{Request.Scheme}://{Request.Host}");
|
||||
return Ok(cfg.MessageOfTheDay);
|
||||
}
|
||||
|
||||
[HttpGet("objectives")]
|
||||
public ActionResult<List<object>> GetObjectives() => Ok(new List<object>());
|
||||
|
||||
// AmplitudeConfig.Deserialize expects key "AmplitudeKey"
|
||||
[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
|
||||
/// (RegistrationSceneNUX → ChooseCohortScreen). Each button maps to
|
||||
/// a room the new player can teleport into.
|
||||
///
|
||||
/// Wire shape per CohortNUXButtonConfig (TypeDefIndex 11516) — list of:
|
||||
/// Version (int)
|
||||
/// ButtonNumber (int, 0..3 → Button1..Button4)
|
||||
/// Override (int, 0=None, 1=Hotlist, 2=Custom)
|
||||
/// CustomRoomName (string)
|
||||
/// CustomTitle (string)
|
||||
/// DefaultRoomName (string)
|
||||
/// DefaultTitle (string)
|
||||
///
|
||||
/// The screen picks `CustomRoomName` if Override=Custom, else the
|
||||
/// `DefaultRoomName` (resolved by Rooms.GetByName). For simplicity all
|
||||
/// four use Override=None and route to Rec Room Original rooms we
|
||||
/// already seeded — picking the most newcomer-friendly ones.
|
||||
/// </summary>
|
||||
[HttpGet("cohortnux/{cohortId:int}")]
|
||||
public IActionResult GetCohortNux(int cohortId) => Ok(new[]
|
||||
{
|
||||
new {
|
||||
Version = 1, ButtonNumber = 0, Override = 0,
|
||||
CustomRoomName = "", CustomTitle = "",
|
||||
DefaultRoomName = "RecCenter",
|
||||
DefaultTitle = "Hang out at the Rec Center",
|
||||
},
|
||||
new {
|
||||
Version = 1, ButtonNumber = 1, Override = 0,
|
||||
CustomRoomName = "", CustomTitle = "",
|
||||
DefaultRoomName = "Paintball",
|
||||
DefaultTitle = "Play Paintball",
|
||||
},
|
||||
new {
|
||||
Version = 1, ButtonNumber = 2, Override = 0,
|
||||
CustomRoomName = "", CustomTitle = "",
|
||||
DefaultRoomName = "Dodgeball",
|
||||
DefaultTitle = "Play Dodgeball",
|
||||
},
|
||||
new {
|
||||
Version = 1, ButtonNumber = 3, Override = 0,
|
||||
CustomRoomName = "", CustomTitle = "",
|
||||
DefaultRoomName = "GoldenTrophy",
|
||||
DefaultTitle = "Quest for the Golden Trophy",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Config.V1;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full Rec Room service URL map. Some builds fetch this standalone
|
||||
/// at /api/config/v1/services to discover where the other subdomains live.
|
||||
/// Apex comes from <see cref="DomainConfig"/> (DORKNET_DOMAIN), so a single
|
||||
/// env var controls every URL emitted.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/config/v1")]
|
||||
public class ServiceUrlsController(DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet("services")]
|
||||
public ActionResult<Dictionary<string, string>> Get()
|
||||
=> Ok(ConfigService.BuildServiceUrlMap(domain.Apex));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Models.Config;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Config.V2;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
public class ConfigController(ConfigService configService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public ActionResult<RecRoomConfig> GetConfig()
|
||||
{
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
return Ok(configService.GetConfig(baseUrl));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Events.V3;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v3")]
|
||||
public class EventsController : ControllerBase
|
||||
{
|
||||
[HttpGet("list")]
|
||||
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameConfigurations;
|
||||
|
||||
[ApiController]
|
||||
public class GameConfigurationsController(ServerSettingsService serverSettings) : ControllerBase
|
||||
{
|
||||
// Actual path used by the 2020 build
|
||||
[HttpGet("api/gameconfigs/v1/all")]
|
||||
[HttpGet("api/gameconfigs/v1")]
|
||||
// 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());
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Models.GameSessions;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameSessions.V2;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
[Authorize]
|
||||
public class GameSessionsController(GameSessionService sessionService) : ControllerBase
|
||||
{
|
||||
private long CurrentPlayerId => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpPost("joinRandom")]
|
||||
public async Task<ActionResult<JoinGameSessionResponse>> JoinRandom([FromBody] JoinRandomGameSessionRequest req)
|
||||
{
|
||||
var session = await sessionService.JoinOrCreateAsync(req.RoomId, req.ActivityLevelId, req.RegionId);
|
||||
return Ok(new JoinGameSessionResponse
|
||||
{
|
||||
Result = JoinGameErrorCode.Success,
|
||||
GameSession = session,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("joinById")]
|
||||
public async Task<ActionResult<JoinGameSessionResponse>> JoinById([FromBody] JoinByIdRequest req)
|
||||
{
|
||||
var session = await sessionService.GetByIdAsync(req.GameSessionId);
|
||||
if (session is null)
|
||||
{
|
||||
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.NotFound });
|
||||
}
|
||||
|
||||
if (session.IsFull)
|
||||
{
|
||||
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Full });
|
||||
}
|
||||
|
||||
return Ok(new JoinGameSessionResponse
|
||||
{
|
||||
Result = JoinGameErrorCode.Success,
|
||||
GameSession = session,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("leave")]
|
||||
public async Task<ActionResult> Leave([FromBody] long sessionId)
|
||||
{
|
||||
await sessionService.PlayerLeftAsync(sessionId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("{id:long}")]
|
||||
public async Task<ActionResult<GameSession>> GetSession(long id)
|
||||
{
|
||||
var session = await sessionService.GetByIdAsync(id);
|
||||
if (session is null) return NotFound();
|
||||
return Ok(session);
|
||||
}
|
||||
}
|
||||
|
||||
public class JoinByIdRequest
|
||||
{
|
||||
public long GameSessionId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/groups/* — clubs / groups. Wire types from
|
||||
/// <c>RecNet.Group</c> + <c>RecNet.GroupMembership</c>:
|
||||
///
|
||||
/// • Group: <c>GroupId, Name, Description, CreatedAt, ImageName,
|
||||
/// BanStatus, CreatorId, NumMembers, Members</c>.
|
||||
/// • GroupMembership: <c>GroupId, PlayerId, Permissions</c>
|
||||
/// (GroupMembershipPermissions flags).
|
||||
///
|
||||
/// URL surface (verb + template) verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Groups.txt</c>:
|
||||
/// GET api/groups/v1/{id}
|
||||
/// GET api/groups/v1/name/{name}
|
||||
/// GET api/groups/v1/memberships/{playerId}
|
||||
/// POST api/groups/v1 (create — body CreateGroupRequest)
|
||||
/// POST api/groups/v1/delete/{id}
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class GroupsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
private long? MeOrNull => this.CurrentPlayerId();
|
||||
|
||||
[HttpGet("api/groups/v1/{id:long}")]
|
||||
public async Task<IActionResult> GetById(long id)
|
||||
{
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
var members = await db.ClubMemberships
|
||||
.Where(m => m.ClubId == id)
|
||||
.ToListAsync();
|
||||
return Ok(ToWire(g, members));
|
||||
}
|
||||
|
||||
[HttpGet("api/groups/v1/name/{name}")]
|
||||
public async Task<IActionResult> GetByName(string name)
|
||||
{
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Name == name);
|
||||
if (g is null) return NotFound();
|
||||
var members = await db.ClubMemberships
|
||||
.Where(m => m.ClubId == g.Id)
|
||||
.ToListAsync();
|
||||
return Ok(ToWire(g, members));
|
||||
}
|
||||
|
||||
[HttpGet("api/groups/v1/memberships/{playerId:long}")]
|
||||
public async Task<IActionResult> Memberships(long playerId)
|
||||
{
|
||||
var rows = await (from m in db.ClubMemberships
|
||||
join c in db.Clubs on m.ClubId equals c.Id
|
||||
where m.PlayerId == playerId
|
||||
select new { m, c }).ToListAsync();
|
||||
return Ok(rows.Select(r => new
|
||||
{
|
||||
GroupId = r.c.Id,
|
||||
PlayerId = (int)r.m.PlayerId,
|
||||
Permissions = r.m.Permissions,
|
||||
Group = ToWire(r.c, new List<ClubMembershipEntity>()),
|
||||
}));
|
||||
}
|
||||
|
||||
public sealed class CreateGroupRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/groups/v1")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Create([FromBody] CreateGroupRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name))
|
||||
return BadRequest(new { Result = 1, Error = "name required" });
|
||||
var pid = Me;
|
||||
var dupe = await db.Clubs.AnyAsync(c => c.Name == req.Name);
|
||||
if (dupe) return Conflict(new { Result = 2, Error = "name taken" });
|
||||
|
||||
var club = new ClubEntity
|
||||
{
|
||||
Name = req.Name.Trim(),
|
||||
Description = req.Description ?? string.Empty,
|
||||
ImageName = req.ImageName ?? string.Empty,
|
||||
CreatorPlayerId = pid,
|
||||
BanStatus = 0,
|
||||
};
|
||||
db.Clubs.Add(club);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Owner membership uses GroupMembershipPermissions.Owner = 127.
|
||||
db.ClubMemberships.Add(new ClubMembershipEntity
|
||||
{
|
||||
ClubId = club.Id,
|
||||
PlayerId = pid,
|
||||
Permissions = 127,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Result = 0,
|
||||
Group = ToWire(club, new List<ClubMembershipEntity>
|
||||
{
|
||||
new() { ClubId = club.Id, PlayerId = pid, Permissions = 127 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class UpdateGroupRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/groups/v1/{id}</c> — owner-only update of
|
||||
/// description / image (per <c>RecNet.Groups.UpdateGroup</c> body
|
||||
/// shape). Mirrors the create response so the watch's
|
||||
/// <c>CreateModifyGroupResponse</c> deserialiser parses both.</summary>
|
||||
[HttpPost("api/groups/v1/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Update(long id, [FromBody] UpdateGroupRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
if (g.CreatorPlayerId != pid) return Forbid();
|
||||
if (req.Description is not null) g.Description = req.Description;
|
||||
if (req.ImageName is not null) g.ImageName = req.ImageName;
|
||||
// Name updates are routed to the dedicated endpoint below
|
||||
// (uniqueness check + name index has to round-trip).
|
||||
g.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
|
||||
return Ok(new { Result = 0, Group = ToWire(g, members) });
|
||||
}
|
||||
|
||||
public sealed class UpdateGroupNameRequest { public string? Name { get; set; } }
|
||||
|
||||
/// <summary>POST <c>api/groups/v1/name/{id}</c> — owner-only
|
||||
/// rename. Unique-name collision returns Result=2 to match the
|
||||
/// client's <c>CreateModifyGroupStatus</c> enum.</summary>
|
||||
[HttpPost("api/groups/v1/name/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateName(long id, [FromBody] UpdateGroupNameRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name))
|
||||
return BadRequest(new { Result = 1, Error = "name required" });
|
||||
var pid = Me;
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
if (g.CreatorPlayerId != pid) return Forbid();
|
||||
var dupe = await db.Clubs.AnyAsync(c => c.Id != id && c.Name == req.Name);
|
||||
if (dupe) return Conflict(new { Result = 2, Error = "name taken" });
|
||||
g.Name = req.Name.Trim();
|
||||
g.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
|
||||
return Ok(new { Result = 0, Group = ToWire(g, members) });
|
||||
}
|
||||
|
||||
[HttpPost("api/groups/v1/delete/{id:long}")]
|
||||
[HttpDelete("api/groups/v1/delete/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete(long id)
|
||||
{
|
||||
var pid = Me;
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
if (g.CreatorPlayerId != pid) return Forbid();
|
||||
var memberships = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
|
||||
db.ClubMemberships.RemoveRange(memberships);
|
||||
db.Clubs.Remove(g);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Deleted = true });
|
||||
}
|
||||
|
||||
[HttpPost("api/groups/v1/{id:long}/join")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Join(long id)
|
||||
{
|
||||
var pid = Me;
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
var existing = await db.ClubMemberships
|
||||
.FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid);
|
||||
if (existing is not null) return Ok(new { Joined = true });
|
||||
db.ClubMemberships.Add(new ClubMembershipEntity
|
||||
{
|
||||
ClubId = id,
|
||||
PlayerId = pid,
|
||||
Permissions = 0, // Member
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Joined = true });
|
||||
}
|
||||
|
||||
[HttpPost("api/groups/v1/{id:long}/leave")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Leave(long id)
|
||||
{
|
||||
var pid = Me;
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
if (g.CreatorPlayerId == pid)
|
||||
return BadRequest(new { Error = "owner cannot leave; transfer ownership first" });
|
||||
var row = await db.ClubMemberships
|
||||
.FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid);
|
||||
if (row is null) return NotFound();
|
||||
db.ClubMemberships.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Left = true });
|
||||
}
|
||||
|
||||
private static object ToWire(ClubEntity g, List<ClubMembershipEntity> members) => new
|
||||
{
|
||||
GroupId = g.Id,
|
||||
g.Name,
|
||||
g.Description,
|
||||
g.CreatedAt,
|
||||
g.ImageName,
|
||||
BanStatus = g.BanStatus,
|
||||
CreatorId = (int)g.CreatorPlayerId,
|
||||
NumMembers = members.Count,
|
||||
Members = members.Select(m => new
|
||||
{
|
||||
GroupId = g.Id,
|
||||
PlayerId = (int)m.PlayerId,
|
||||
Permissions = m.Permissions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,1017 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Images;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/images/v* — in-game camera photo endpoints. The 2020
|
||||
/// watch's camera UI ("save photo to album", "share photo") posts here.
|
||||
///
|
||||
/// Wire shape (observed from server logs — the 2020 watch hits these
|
||||
/// without a corresponding storage.rec.net/upload, meaning the bytes
|
||||
/// come straight in this request body):
|
||||
/// POST api/images/v4/uploadsaved
|
||||
/// - body: multipart/form-data with the image file plus optional
|
||||
/// fields (Caption, Description, RoomId, IsPublic, TaggedPlayerIds).
|
||||
/// On the 2020 client the field name varies — we accept "File",
|
||||
/// "Image", and any *.png/*.jpg upload for safety.
|
||||
/// - response: an ImageInfo-shaped object (PascalCase keys). The
|
||||
/// watch's success path calls a JSON parser whose strict keys are
|
||||
/// AccountId, ImageName, CreatedAt — so all three are required.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ImagesController(
|
||||
DorkNetDbContext db,
|
||||
PlayerPresenceService presence,
|
||||
LevelService level,
|
||||
IObjectStorage storage,
|
||||
ImageSignatureService signatures,
|
||||
NotificationService notifications,
|
||||
DomainConfig domain,
|
||||
ILogger<ImagesController> logger) : ControllerBase
|
||||
{
|
||||
private static readonly string ImageDir =
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
|
||||
private static readonly byte[] TransparentPng = new byte[]
|
||||
{
|
||||
0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,
|
||||
0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,
|
||||
0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89,0x00,0x00,0x00,
|
||||
0x0D,0x49,0x44,0x41,0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00,
|
||||
0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00,0x00,0x00,0x00,0x49,
|
||||
0x45,0x4E,0x44,0xAE,0x42,0x60,0x82,
|
||||
};
|
||||
|
||||
/// <summary>POST api/images/v{2-5}/uploadsaved — receive an image
|
||||
/// from the in-game camera "save to album" button. Persists the
|
||||
/// bytes in <see cref="RoomDataBlobEntity"/> and creates a
|
||||
/// <see cref="PhotoEntity"/> row keyed by the uploader. The photo
|
||||
/// defaults to IsPublic=false (saved-only) so it doesn't surface
|
||||
/// on the public feed unless the user explicitly hits Share.</summary>
|
||||
[HttpPost("api/images/v2/uploadsaved")]
|
||||
[HttpPost("api/images/v3/uploadsaved")]
|
||||
[HttpPost("api/images/v4/uploadsaved")]
|
||||
[HttpPost("api/images/v5/uploadsaved")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UploadSaved()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var bytes = await ReadImageBytesAsync();
|
||||
if (bytes is null || bytes.Length == 0)
|
||||
{
|
||||
logger.LogWarning("[images] uploadsaved by {Pid}: no file bytes received", pid);
|
||||
return BadRequest(new { error = "missing_file" });
|
||||
}
|
||||
|
||||
// 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 isProfileThumbnail = IsProfileThumbnail(form);
|
||||
LogUploadForm("uploadsaved", pid, form, isProfileThumbnail);
|
||||
|
||||
// Detect format. The watch uses PNG; if a JPEG slips through we
|
||||
// tag it accordingly so the cdn serves the right MIME.
|
||||
var extension = SniffExtension(bytes);
|
||||
var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}";
|
||||
|
||||
// Dual-write during the migration window: S3 is the canonical
|
||||
// store going forward, but we keep the DB Bytes column populated
|
||||
// so reads from older code paths still work. PR 4 drops the
|
||||
// column once every blob has a confirmed S3 copy.
|
||||
try
|
||||
{
|
||||
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4));
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
await storage.PutAsync(
|
||||
bucket, key, bytes,
|
||||
extension == "png" ? "image/png" : "image/jpeg",
|
||||
s3Timeout.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"[images] S3 upload failed for {Blob}; keeping DB fallback so the client does not fail",
|
||||
blobName);
|
||||
}
|
||||
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = 0,
|
||||
BlobName = blobName,
|
||||
UploadedByPlayerId = pid,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
ReferencedFilenamesCsv = string.Empty,
|
||||
});
|
||||
|
||||
var photo = new PhotoEntity
|
||||
{
|
||||
UploaderPlayerId = pid,
|
||||
BlobName = blobName,
|
||||
Caption = caption.Trim(),
|
||||
TaggedPlayerIdsCsv = NormaliseTaggedIds(taggedIds, exceptId: pid),
|
||||
RoomId = roomId,
|
||||
IsPublic = isPublic,
|
||||
};
|
||||
db.Photos.Add(photo);
|
||||
|
||||
if (isProfileThumbnail)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is not null)
|
||||
{
|
||||
player.ProfileImageName = blobName;
|
||||
logger.LogInformation(
|
||||
"[images] profile thumbnail upload set profile image: player={PlayerId} blob={Blob}",
|
||||
pid,
|
||||
blobName);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (isProfileThumbnail)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is not null)
|
||||
await NotifyAccountImageChangedAsync(player);
|
||||
}
|
||||
|
||||
// Small XP bump for taking a photo — encourages camera use.
|
||||
await level.AwardXpAsync(pid, LevelService.RoomVisitXp, $"photo_saved:{photo.Id}");
|
||||
|
||||
logger.LogInformation(
|
||||
"[images] uploaded saved photo {Id} by {Pid} ({Bytes} bytes) → {Blob}",
|
||||
photo.Id, pid, bytes.Length, blobName);
|
||||
|
||||
return Ok(BuildImageInfo(photo, pid));
|
||||
}
|
||||
|
||||
/// <summary>POST api/images/v{1-5}/profile — direct profile image upload.
|
||||
/// Older clients use this instead of tagging an uploadsaved request as a
|
||||
/// ProfileThumbnail, so this route must always update Account.ProfileImage.</summary>
|
||||
[HttpPost("api/images/v1/profile")]
|
||||
[HttpPost("api/images/v2/profile")]
|
||||
[HttpPost("api/images/v3/profile")]
|
||||
[HttpPost("api/images/v4/profile")]
|
||||
[HttpPost("api/images/v5/profile")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UploadProfile()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var bytes = await ReadImageBytesAsync();
|
||||
if (bytes is null || bytes.Length == 0)
|
||||
{
|
||||
logger.LogWarning("[images] profile upload by {Pid}: no file bytes received", pid);
|
||||
return BadRequest(new { error = "missing_file" });
|
||||
}
|
||||
|
||||
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
|
||||
LogUploadForm("profile", pid, form, isProfile: true);
|
||||
|
||||
var extension = SniffExtension(bytes);
|
||||
var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}";
|
||||
var contentType = extension == "png" ? "image/png" : "image/jpeg";
|
||||
|
||||
try
|
||||
{
|
||||
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4));
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
await storage.PutAsync(bucket, key, bytes, contentType, s3Timeout.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"[images] profile S3 upload failed for {Blob}; keeping DB fallback so the client does not fail",
|
||||
blobName);
|
||||
}
|
||||
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = 0,
|
||||
BlobName = blobName,
|
||||
UploadedByPlayerId = pid,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
ReferencedFilenamesCsv = string.Empty,
|
||||
});
|
||||
|
||||
var photo = new PhotoEntity
|
||||
{
|
||||
UploaderPlayerId = pid,
|
||||
BlobName = blobName,
|
||||
Caption = string.Empty,
|
||||
TaggedPlayerIdsCsv = string.Empty,
|
||||
RoomId = presence.GetRoom(pid)?.RoomId ?? 0,
|
||||
IsPublic = false,
|
||||
};
|
||||
db.Photos.Add(photo);
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is not null)
|
||||
player.ProfileImageName = blobName;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (player is not null)
|
||||
await NotifyAccountImageChangedAsync(player);
|
||||
|
||||
logger.LogInformation(
|
||||
"[images] uploaded profile photo {Id} by {Pid} ({Bytes} bytes) -> {Blob}",
|
||||
photo.Id, pid, bytes.Length, blobName);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ImageName = blobName,
|
||||
AccountId = (int)pid,
|
||||
CreatedAt = photo.CreatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET api/images/v{2-5}/saved — list the caller's saved
|
||||
/// photos. The watch's "My Photos" tab calls this to populate the
|
||||
/// album view.</summary>
|
||||
[HttpGet("api/images/v2/saved")]
|
||||
[HttpGet("api/images/v3/saved")]
|
||||
[HttpGet("api/images/v4/saved")]
|
||||
[HttpGet("api/images/v5/saved")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetSaved()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v2/named</c> — the watch's
|
||||
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
|
||||
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
|
||||
/// response (the watch's <c>ExpectListResponse</c> rejects an
|
||||
/// object body but tolerates an empty array).</summary>
|
||||
[HttpGet("api/images/v2/named")]
|
||||
public IActionResult NamedImages() => Ok(Array.Empty<object>());
|
||||
|
||||
/// <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>
|
||||
[HttpGet("api/images/v1/slideshow")]
|
||||
public async Task<IActionResult> SlideshowInfo()
|
||||
{
|
||||
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)),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Binary image fallback for clients that request image bytes
|
||||
/// through api/images/{name} instead of img.rec.net/{name}. Specific JSON
|
||||
/// endpoints above still win route matching; this only handles CDN-shaped
|
||||
/// image paths.</summary>
|
||||
[HttpGet("api/images/{*path}", Order = 100)]
|
||||
public async Task<IActionResult> ServeImageFromApiHost(string? path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path ?? string.Empty);
|
||||
// Strip the leading '$' sigil that ImageData.image_name uses for
|
||||
// hash-addressed assets. Watch sends "%24<hash>.jpg"; route
|
||||
// decode gives us "$<hash>.jpg"; the bytes are stored at
|
||||
// "<hash>.jpg". Without the strip, placed in-world polaroids
|
||||
// miss in S3 and the watch renders the "?" fallback.
|
||||
if (!string.IsNullOrEmpty(fileName) && fileName[0] == '$') fileName = fileName[1..];
|
||||
if (string.IsNullOrWhiteSpace(fileName) || !IsImageName(fileName))
|
||||
return NotFound();
|
||||
|
||||
var result = await LoadImageBytesAsync(fileName);
|
||||
if (result.Bytes is { Length: > 0 })
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[images] api image hit host={Host} path={Path} file={File} source={Source} bytes={Bytes} query='{Query}'",
|
||||
Request.Host.Host,
|
||||
path,
|
||||
fileName,
|
||||
result.Source,
|
||||
result.Bytes.Length,
|
||||
Request.QueryString);
|
||||
Response.Headers.CacheControl = "public, max-age=300";
|
||||
signatures.AddContentSignature(Response, result.Bytes);
|
||||
return File(result.Bytes, MimeFromName(fileName));
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"[images] api image MISS host={Host} path={Path} file={File} query='{Query}' -> transparent png",
|
||||
Request.Host.Host,
|
||||
path,
|
||||
fileName,
|
||||
Request.QueryString);
|
||||
Response.Headers.CacheControl = "public, max-age=60";
|
||||
signatures.AddContentSignature(Response, TransparentPng);
|
||||
return File(TransparentPng, "image/png");
|
||||
}
|
||||
|
||||
/// <summary>GET api/images/v{2-5}/byaccount/{accountId} — list a
|
||||
/// player's PUBLIC photos. Drives the "their album" tab in
|
||||
/// the watch's player profile.</summary>
|
||||
[HttpGet("api/images/v2/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
|
||||
public async Task<IActionResult> GetByAccount(long accountId,
|
||||
[FromQuery] int take = 50, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
skip = Math.Max(0, skip);
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
|
||||
}
|
||||
|
||||
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
|
||||
/// to public so it shows up on feed.rec.net. The watch's "Share"
|
||||
/// button hits this; if the watch posts to a different URL we'll
|
||||
/// add it when we see it in logs.</summary>
|
||||
[HttpPost("api/images/v2/share/{id:long}")]
|
||||
[HttpPost("api/images/v3/share/{id:long}")]
|
||||
[HttpPost("api/images/v4/share/{id:long}")]
|
||||
[HttpPost("api/images/v5/share/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Share(long id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return NotFound();
|
||||
if (photo.UploaderPlayerId != pid) return Forbid();
|
||||
photo.IsPublic = true;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(BuildImageInfo(photo, pid));
|
||||
}
|
||||
|
||||
/// <summary>DELETE api/images/v{2-5}/{id} — uploader (or admin)
|
||||
/// soft-deletes a saved photo.</summary>
|
||||
[HttpDelete("api/images/v2/{id:long}")]
|
||||
[HttpDelete("api/images/v3/{id:long}")]
|
||||
[HttpDelete("api/images/v4/{id:long}")]
|
||||
[HttpDelete("api/images/v5/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete(long id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return NotFound();
|
||||
if (photo.UploaderPlayerId != pid)
|
||||
{
|
||||
var isAdmin = await db.Players.Where(x => x.Id == pid).Select(x => x.IsAdmin).FirstOrDefaultAsync();
|
||||
if (!isAdmin) return Forbid();
|
||||
}
|
||||
photo.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { deleted = id });
|
||||
}
|
||||
|
||||
// ── Legacy v1 ops (real persistence, was Ack-only stubs) ─────────
|
||||
|
||||
public sealed class CheerImageRequest
|
||||
{
|
||||
public long ImageId { get; set; }
|
||||
public long PhotoId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BulkCheeredRequest
|
||||
{
|
||||
public List<long>? SavedImageIds { get; set; }
|
||||
public List<long>? ImageIds { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/images/v4/cheered/bulk")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new())
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var cheered = await db.Cheers
|
||||
.Where(c => c.FromPlayerId == pid && ids.Contains(c.TargetPhotoId))
|
||||
.Select(c => c.TargetPhotoId)
|
||||
.ToListAsync();
|
||||
var set = cheered.ToHashSet();
|
||||
return Ok(ids.Select(id => new
|
||||
{
|
||||
SavedImageId = id,
|
||||
IsCheered = set.Contains(id),
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
|
||||
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
|
||||
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
|
||||
/// Idempotent per (player, photo).</summary>
|
||||
[HttpPost("api/images/v1/cheer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheerV1([FromBody] CheerImageRequest? body,
|
||||
[FromForm(Name = "ImageId")] long? imageIdForm,
|
||||
[FromForm(Name = "PhotoId")] long? photoIdForm)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photoId = body?.PhotoId ?? body?.ImageId
|
||||
?? photoIdForm ?? imageIdForm ?? 0;
|
||||
if (photoId <= 0) return BadRequest(new { error = "missing photoId" });
|
||||
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId && p.DeletedAt == null);
|
||||
if (photo is null) return NotFound();
|
||||
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == pid && c.TargetPhotoId == photoId);
|
||||
if (existing is null)
|
||||
{
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
FromPlayerId = pid,
|
||||
TargetPhotoId = photoId,
|
||||
CheeredAt = DateTime.UtcNow,
|
||||
});
|
||||
photo.CheerCount += 1;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(new { photo.Id, photo.CheerCount });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v1/listsaved</c> — the 2020 client
|
||||
/// deserializes this as SavedImagesListDTO { Images: string[] }.
|
||||
/// Do not return the richer v2/v4 photo-object list here.</summary>
|
||||
[HttpGet("api/images/v1/listsaved")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ListSavedV1()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var names = await db.Photos
|
||||
.Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Select(p => p.BlobName)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(new { Images = names });
|
||||
}
|
||||
|
||||
public sealed class ModifyAccessibilityRequest
|
||||
{
|
||||
public long PhotoId { get; set; }
|
||||
public long ImageId { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public bool IsPublic { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/modifyaccessibility</c> — flip
|
||||
/// the IsPublic flag on a saved photo. Was Ack-only; now writes
|
||||
/// the column.</summary>
|
||||
[HttpPost("api/images/v1/modifyaccessibility")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ModifyAccessibility(
|
||||
[FromBody] ModifyAccessibilityRequest? body,
|
||||
[FromForm(Name = "PhotoId")] long? photoIdForm,
|
||||
[FromForm(Name = "ImageId")] long? imageIdForm,
|
||||
[FromForm(Name = "ImageName")] string? imageNameForm,
|
||||
[FromForm(Name = "IsPublic")] bool? isPublicForm)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photoId = body?.PhotoId ?? body?.ImageId
|
||||
?? photoIdForm ?? imageIdForm ?? 0;
|
||||
var imageName = body?.ImageName ?? imageNameForm;
|
||||
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
|
||||
return BadRequest(new { error = "missing photoId" });
|
||||
var isPublic = body?.IsPublic ?? isPublicForm ?? true;
|
||||
|
||||
var photo = photoId > 0
|
||||
? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId)
|
||||
: await db.Photos.FirstOrDefaultAsync(p => p.BlobName == imageName);
|
||||
if (photo is null) return NotFound();
|
||||
if (photo.UploaderPlayerId != pid) return Forbid();
|
||||
photo.IsPublic = isPublic;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { photo.Id, photo.IsPublic });
|
||||
}
|
||||
|
||||
public sealed class DeleteSavedRequest
|
||||
{
|
||||
public long PhotoId { get; set; }
|
||||
public long ImageId { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/deletesaved</c> + DELETE alias —
|
||||
/// soft-delete a saved photo. Was Ack-only; now flips
|
||||
/// <c>DeletedAt</c>.</summary>
|
||||
[HttpPost("api/images/v1/deletesaved")]
|
||||
[HttpDelete("api/images/v1/deletesaved/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteSavedV1(
|
||||
long? id,
|
||||
[FromBody] DeleteSavedRequest? body,
|
||||
[FromForm(Name = "PhotoId")] long? photoIdForm,
|
||||
[FromForm(Name = "ImageId")] long? imageIdForm,
|
||||
[FromForm(Name = "ImageName")] string? imageNameForm)
|
||||
{
|
||||
var photoId = id ?? body?.PhotoId ?? body?.ImageId
|
||||
?? photoIdForm ?? imageIdForm ?? 0;
|
||||
var imageName = body?.ImageName ?? imageNameForm;
|
||||
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
|
||||
return BadRequest(new { error = "missing photoId" });
|
||||
if (photoId <= 0)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p =>
|
||||
p.UploaderPlayerId == pid && p.BlobName == imageName && p.DeletedAt == null);
|
||||
if (photo is null) return NotFound();
|
||||
photo.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
return await Delete(photoId);
|
||||
}
|
||||
|
||||
public sealed class SendLinkRequest
|
||||
{
|
||||
public long PhotoId { get; set; }
|
||||
public long ImageId { get; set; }
|
||||
public List<long>? RecipientIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/sendlink</c> — send a link to a
|
||||
/// photo as a DM to one or more recipients. Was Ack-only; now
|
||||
/// inserts one <see cref="MessageEntity"/> per recipient.</summary>
|
||||
[HttpPost("api/images/v1/sendlink")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SendLink(
|
||||
[FromBody] SendLinkRequest? body,
|
||||
[FromForm(Name = "PhotoId")] long? photoIdForm,
|
||||
[FromForm(Name = "ImageId")] long? imageIdForm,
|
||||
[FromForm(Name = "RecipientIds")] string? recipientIdsForm)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photoId = body?.PhotoId ?? body?.ImageId ?? photoIdForm ?? imageIdForm ?? 0;
|
||||
if (photoId <= 0) return BadRequest(new { error = "missing photoId" });
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId);
|
||||
if (photo is null) return NotFound();
|
||||
|
||||
var recipients = body?.RecipientIds ?? ParseIdList(recipientIdsForm) ?? new();
|
||||
if (recipients.Count == 0) return Ok(new { Sent = 0 });
|
||||
|
||||
var preview = $"shared a photo (id {photoId})";
|
||||
foreach (var rid in recipients.Distinct())
|
||||
{
|
||||
db.Messages.Add(new MessageEntity
|
||||
{
|
||||
SenderPlayerId = pid,
|
||||
RecipientPlayerId = rid,
|
||||
Body = $"{preview} → /img/{photo.BlobName}",
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Sent = recipients.Count });
|
||||
}
|
||||
|
||||
private static List<long>? ParseIdList(string? csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) return null;
|
||||
return csv.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => long.TryParse(s.Trim(), out var v) ? v : 0L)
|
||||
.Where(v => v > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Read raw image bytes from the request — supports both
|
||||
/// multipart/form-data (the typical case, with field "File" or
|
||||
/// "Image") and a raw octet-stream body (some 2020 client paths).</summary>
|
||||
private async Task<byte[]?> ReadImageBytesAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
// Try common field names; fall back to the first uploaded file.
|
||||
var file = form.Files["File"]
|
||||
?? form.Files["Image"]
|
||||
?? form.Files["image"]
|
||||
?? form.Files["file"]
|
||||
?? form.Files.FirstOrDefault();
|
||||
if (file is null || file.Length == 0) return null;
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
// Raw body fallback — when the watch posts the bytes directly.
|
||||
if (Request.ContentLength is > 0)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await Request.Body.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Detect PNG vs JPEG by magic bytes — anything else
|
||||
/// defaults to .png since the 2020 client's camera always saves
|
||||
/// PNG.</summary>
|
||||
private static string SniffExtension(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length >= 4 &&
|
||||
bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
|
||||
return "png";
|
||||
if (bytes.Length >= 3 &&
|
||||
bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
|
||||
return "jpg";
|
||||
return "png";
|
||||
}
|
||||
|
||||
private async Task<(byte[]? Bytes, string Source)> LoadImageBytesAsync(string fileName)
|
||||
{
|
||||
var (bucket, key) = BlobRouter.Route(fileName);
|
||||
try
|
||||
{
|
||||
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
var s3Bytes = await storage.GetAsync(bucket, key, s3Timeout.Token);
|
||||
if (s3Bytes is { Length: > 0 })
|
||||
return (s3Bytes, $"s3:{bucket}/{key}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex,
|
||||
"[images] API image S3 lookup threw for {File} bucket={Bucket} key={Key}",
|
||||
fileName, bucket, key);
|
||||
}
|
||||
|
||||
// S3 is the only canonical store. Disk-on-server is a legacy
|
||||
// surface used by tools/fetch-room-images.py'd thumbnails that
|
||||
// ship with the server — keep that as a last resort, but DB
|
||||
// bytes are gone.
|
||||
var full = Path.Combine(ImageDir, fileName);
|
||||
if (System.IO.File.Exists(full))
|
||||
return (await System.IO.File.ReadAllBytesAsync(full), "disk");
|
||||
|
||||
return (null, "miss");
|
||||
}
|
||||
|
||||
|
||||
private static bool TryGetPlayerId(string fileName, string prefix, out long playerId)
|
||||
{
|
||||
playerId = 0;
|
||||
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
var start = prefix.Length;
|
||||
var end = fileName.IndexOf('_', start);
|
||||
return end > start && long.TryParse(fileName[start..end], out playerId);
|
||||
}
|
||||
|
||||
private static bool IsImageName(string name)
|
||||
{
|
||||
var ext = Path.GetExtension(name).ToLowerInvariant();
|
||||
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp" or ".avif";
|
||||
}
|
||||
|
||||
private static string MimeFromName(string name)
|
||||
{
|
||||
var ext = Path.GetExtension(name).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".avif" => "image/avif",
|
||||
_ => "image/png",
|
||||
};
|
||||
}
|
||||
|
||||
private void LogUploadForm(string endpoint, long playerId, IFormCollection? form, bool isProfile)
|
||||
{
|
||||
if (form is null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[images] {Endpoint} metadata player={PlayerId} form=false profile={Profile}",
|
||||
endpoint,
|
||||
playerId,
|
||||
isProfile);
|
||||
return;
|
||||
}
|
||||
|
||||
var fileNames = string.Join(
|
||||
",",
|
||||
form.Files.Select(f => $"{f.Name}:{f.FileName}:{f.Length}"));
|
||||
var fieldNames = string.Join(",", form.Keys);
|
||||
var meta = form["imgMeta"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
meta = form["imageMeta"].ToString();
|
||||
if (meta.Length > 512)
|
||||
meta = meta[..512];
|
||||
|
||||
logger.LogInformation(
|
||||
"[images] {Endpoint} metadata player={PlayerId} files={Files} fields={Fields} profile={Profile} imgMeta={ImgMeta}",
|
||||
endpoint,
|
||||
playerId,
|
||||
fileNames,
|
||||
fieldNames,
|
||||
isProfile,
|
||||
meta);
|
||||
}
|
||||
|
||||
private static bool IsProfileThumbnail(IFormCollection? form)
|
||||
{
|
||||
if (form is null) return false;
|
||||
|
||||
static bool IsProfileValue(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
return value.Trim().Equals("4", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Contains("ProfileThumbnail", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (IsProfileValue(form["SavedImageType"].ToString())
|
||||
|| IsProfileValue(form["savedImageType"].ToString())
|
||||
|| IsProfileValue(form["ImageType"].ToString())
|
||||
|| IsProfileValue(form["imageType"].ToString()))
|
||||
return true;
|
||||
|
||||
var meta = form["imgMeta"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
meta = form["imageMeta"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
return false;
|
||||
|
||||
if (IsProfileValue(meta))
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(meta);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
foreach (var name in new[] { "savedImageType", "SavedImageType", "imageType", "ImageType" })
|
||||
{
|
||||
if (!doc.RootElement.TryGetProperty(name, out var value))
|
||||
continue;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) && n == 4)
|
||||
return true;
|
||||
if (value.ValueKind == JsonValueKind.String && IsProfileValue(value.GetString()))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
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
|
||||
/// most important keys are AccountId, ImageName, CreatedAt — those
|
||||
/// are what the watch's strict deserialiser reads. <c>Url</c>
|
||||
/// is built against the configured deployment apex (DORKNET_DOMAIN)
|
||||
/// so every callsite for the URL string flows through one source
|
||||
/// of truth.</summary>
|
||||
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,
|
||||
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).
|
||||
Filename = p.BlobName,
|
||||
Url = $"https://{cdnHost}/{p.BlobName}",
|
||||
Caption = p.Caption,
|
||||
Description = p.Caption,
|
||||
IsPublic = p.IsPublic,
|
||||
ViewCount = p.ViewCount,
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<long> ParseTagged(string? csv) =>
|
||||
string.IsNullOrWhiteSpace(csv)
|
||||
? Enumerable.Empty<long>()
|
||||
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||
.Where(v => v > 0);
|
||||
|
||||
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
|
||||
{
|
||||
var account = new DorkNet.Models.Auth.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 DorkNet.Models.Auth.RecNetSelfAccount
|
||||
{
|
||||
AccountId = account.AccountId,
|
||||
RawUsername = account.RawUsername,
|
||||
Username = account.Username,
|
||||
DisplayName = account.DisplayName,
|
||||
ProfileImage = account.ProfileImage,
|
||||
TreatAsJunior = account.TreatAsJunior,
|
||||
HasBirthday = account.HasBirthday,
|
||||
Platforms = account.Platforms,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = player.Phone ?? string.Empty,
|
||||
Birthday = player.Birthday,
|
||||
JuniorState = player.IsJunior ? 1 : 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
return Task.WhenAll(
|
||||
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
|
||||
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
|
||||
}
|
||||
|
||||
private static string NormaliseTaggedIds(string? csv, long exceptId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
|
||||
var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||
.Where(v => v > 0 && v != exceptId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return string.Join(",", ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Images.V2;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
[Authorize]
|
||||
public class ImagesController : ControllerBase
|
||||
{
|
||||
private static readonly string ImageDir =
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
|
||||
[HttpPost("upload")]
|
||||
public async Task<ActionResult<UploadImageResponse>> Upload(IFormFile file)
|
||||
{
|
||||
Directory.CreateDirectory(ImageDir);
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (ext is not (".png" or ".jpg" or ".jpeg"))
|
||||
return BadRequest("Only PNG/JPG accepted");
|
||||
|
||||
var name = $"{Guid.NewGuid():N}{ext}";
|
||||
var path = Path.Combine(ImageDir, name);
|
||||
|
||||
await using var stream = System.IO.File.Create(path);
|
||||
await file.CopyToAsync(stream);
|
||||
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
return Ok(new UploadImageResponse { ImageName = name, Url = $"{baseUrl}/img/{name}" });
|
||||
}
|
||||
}
|
||||
|
||||
public class UploadImageResponse
|
||||
{
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public string Url { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Inventions;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/inventions/* — Maker-Pen creations the player can
|
||||
/// re-spawn or share. URL surface + verbs verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Inventions.txt</c>: the "update*"
|
||||
/// routes use <c>Core.Get</c> (GET) despite being mutations, so we
|
||||
/// expose them as [HttpGet]; only <c>cheer</c>, <c>report</c>,
|
||||
/// <c>settags</c>, <c>save</c>, <c>addversion</c>, and <c>batch</c>
|
||||
/// 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
|
||||
/// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description,
|
||||
/// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
|
||||
/// ModifiedAt, CreatedAt, FirstPublishedAt, CreationRoomId,
|
||||
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
|
||||
/// CreatorPermission, GeneralPermission, IsAGInvention, Price,
|
||||
/// HideFromPlayer</c>. Casing is significant — note IsAGInvention
|
||||
/// has a capital G.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class InventionsController(
|
||||
DorkNetDbContext db,
|
||||
LevelService level,
|
||||
NotificationService notifications) : ControllerBase
|
||||
{
|
||||
private long? CurrentPlayerIdOrNull => this.CurrentPlayerId();
|
||||
private long CurrentPlayerId => this.RequireCurrentPlayerId();
|
||||
|
||||
// ── Browse ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/inventions/v3/popular")]
|
||||
public async Task<ActionResult> Popular([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished)
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v3/saved")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Saved([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var pid = CurrentPlayerId;
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.CreatorPlayerId == pid)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v3/created")]
|
||||
[Authorize]
|
||||
public Task<ActionResult> Created([FromQuery] int take = 50) => Saved(take);
|
||||
|
||||
/// <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);
|
||||
|
||||
[HttpGet("api/inventions/v1/search")]
|
||||
public async Task<ActionResult> Search([FromQuery] string value = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
// The watch's @-prefix means "search by player". Two callers
|
||||
// use this with different payload shapes — both end up here:
|
||||
// * PlayerDetailsWatchUIFlow.SetAccount → @<accountId> (numeric)
|
||||
// loads the Inventions tab on a player profile. Verified
|
||||
// in Cpp2IL — see PlayerDetailsWatchUIFlow.txt:1867,1950.
|
||||
// * Inventions search UI → @<username> (text) free-text
|
||||
// prefix match on Username.
|
||||
if (value.StartsWith('@'))
|
||||
{
|
||||
var tail = value[1..];
|
||||
List<long> creators;
|
||||
if (long.TryParse(tail, out var accountId))
|
||||
{
|
||||
creators = new List<long> { accountId };
|
||||
}
|
||||
else
|
||||
{
|
||||
creators = await db.Players
|
||||
.Where(p => p.Username.StartsWith(tail))
|
||||
.Select(p => p.Id).Take(50).ToListAsync();
|
||||
}
|
||||
var byCreator = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished
|
||||
&& creators.Contains(i.CreatorPlayerId))
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
return Ok(byCreator.Select(ToWire));
|
||||
}
|
||||
|
||||
var v = value.ToLowerInvariant();
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished
|
||||
&& (i.Name.ToLower().Contains(v)
|
||||
|| i.TagsCsv.ToLower().Contains(v)))
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record InventionBatchRequest(List<long> InventionIds);
|
||||
|
||||
[HttpPost("api/inventions/v1/batch")]
|
||||
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
||||
{
|
||||
if (req?.InventionIds is null || req.InventionIds.Count == 0)
|
||||
return Ok(Array.Empty<object>());
|
||||
var ids = req.InventionIds.Take(200).ToList();
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
|
||||
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
|
||||
/// response as <c>RecNet.GetFiltersResponse</c> (an
|
||||
/// <c>IRecNetObject</c> with <c>PinnedFilters</c> +
|
||||
/// <c>PopularFilters</c> string-list properties), then a
|
||||
/// <c>Func<GetFiltersResponse, List<string>></c>
|
||||
/// projects whichever list the caller wanted. Verified at
|
||||
/// Cpp2IL_ISIL/.../RecNet/Inventions.txt:4834-4852 — the URL
|
||||
/// literal is followed by a <c>typeof(Func`2<GetFiltersResponse,
|
||||
/// List`1<String>>)</c>. Returning a bare string array
|
||||
/// (the previous shape) tripped the LitJson importer's
|
||||
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize<T></c>
|
||||
/// and surfaced as "Failed to get tags: Malformed Response"
|
||||
/// (output_log.txt:1602+1639) — and that bubbles up as the
|
||||
/// dorm-load destabilisation we've been hunting.</summary>
|
||||
[HttpGet("api/inventions/v1/tagfilters")]
|
||||
public IActionResult TagFilters()
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
"sport", "game", "vehicle", "weapon", "decor", "tool",
|
||||
"art", "music", "puzzle", "combat", "build", "race",
|
||||
};
|
||||
return Ok(new
|
||||
{
|
||||
PinnedFilters = tags,
|
||||
PopularFilters = tags,
|
||||
});
|
||||
}
|
||||
|
||||
/// <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>
|
||||
[HttpGet("api/inventions/v1/creatorIds")]
|
||||
public async Task<IActionResult> CreatorIdsInRoom([FromQuery] long roomId)
|
||||
{
|
||||
var inventions = await ResolveRoomInventionsAsync(roomId);
|
||||
var creatorIds = inventions
|
||||
.Select(i => (int)i.CreatorPlayerId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return Ok(creatorIds);
|
||||
}
|
||||
|
||||
/// <summary>Resolve the inventions that belong to a room. Direct
|
||||
/// matches are inventions whose
|
||||
/// <see cref="InventionEntity.CreationRoomId"/> equals the room's
|
||||
/// local id (RR-Originals, dorms, anything created in-place).
|
||||
/// Zip-imported rooms instead carry each invention's ORIGINAL Rec
|
||||
/// Room CreationRoomId, which won't equal our local id and can't be
|
||||
/// reverse-resolved without an OriginalRoomId column; we approximate
|
||||
/// that cluster as the most-frequent non-local CreationRoomId among
|
||||
/// the room creator's inventions. The two sets are unioned so a room
|
||||
/// with both kinds resolves fully.</summary>
|
||||
private async Task<List<InventionEntity>> ResolveRoomInventionsAsync(long roomId)
|
||||
{
|
||||
var creatorId = await db.Rooms
|
||||
.Where(r => r.Id == roomId)
|
||||
.Select(r => (long?)r.CreatorPlayerId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
long? importedRoomId = null;
|
||||
if (creatorId is long creator)
|
||||
{
|
||||
importedRoomId = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.CreatorPlayerId == creator
|
||||
&& i.CreationRoomId != null && i.CreationRoomId != roomId)
|
||||
.GroupBy(i => i.CreationRoomId)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
return await db.Inventions
|
||||
.Where(i => !i.IsDeleted
|
||||
&& (i.CreationRoomId == roomId
|
||||
|| (importedRoomId != null && i.CreationRoomId == importedRoomId)))
|
||||
.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)
|
||||
{
|
||||
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (i is null) return NotFound();
|
||||
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
|
||||
return Forbid();
|
||||
return Ok(ToWire(i));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/details")]
|
||||
public async Task<ActionResult> Details([FromQuery] long inventionId)
|
||||
{
|
||||
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (i is null) return NotFound();
|
||||
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
|
||||
return Forbid();
|
||||
var versions = await db.InventionVersions
|
||||
.Where(v => v.InventionId == inventionId)
|
||||
.OrderByDescending(v => v.VersionNumber)
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Invention = ToWire(i),
|
||||
Versions = versions.Select(ToVersionWire),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> PersonalDetails(long id)
|
||||
{
|
||||
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
|
||||
if (i is null) return NotFound();
|
||||
return Ok(new
|
||||
{
|
||||
Invention = ToWire(i),
|
||||
CanEdit = i.CreatorPlayerId == CurrentPlayerId,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/versions")]
|
||||
public async Task<ActionResult> Versions([FromQuery] long inventionId)
|
||||
{
|
||||
var rows = await db.InventionVersions
|
||||
.Where(v => v.InventionId == inventionId)
|
||||
.OrderByDescending(v => v.VersionNumber)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToVersionWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/version")]
|
||||
public async Task<ActionResult> Version(
|
||||
[FromQuery] long inventionId, [FromQuery] int version)
|
||||
{
|
||||
var v = await db.InventionVersions
|
||||
.FirstOrDefaultAsync(x => x.InventionId == inventionId && x.VersionNumber == version);
|
||||
if (v is null) return NotFound();
|
||||
return Ok(ToVersionWire(v));
|
||||
}
|
||||
|
||||
// ── 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; }
|
||||
}
|
||||
|
||||
/// <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")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request 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" });
|
||||
|
||||
var inv = new InventionEntity
|
||||
{
|
||||
CreatorPlayerId = pid,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
Name = name,
|
||||
Description = req.Description ?? string.Empty,
|
||||
ImageName = req.ImageName ?? string.Empty,
|
||||
Permission = 0,
|
||||
CurrentBlobName = blobName,
|
||||
TagsCsv = string.Empty,
|
||||
CreationRoomId = req.CreationRoomId,
|
||||
CreatorPermission = 100,
|
||||
GeneralPermission = 0,
|
||||
IsPublished = false,
|
||||
CurrentVersionNumber = 1,
|
||||
};
|
||||
db.Inventions.Add(inv);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
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),
|
||||
};
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record AddVersionRequest(
|
||||
long InventionId, string BlobName,
|
||||
int? InstantiationCost, int? LightsCost);
|
||||
|
||||
[HttpPost("api/inventions/v3/addversion")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
|
||||
|
||||
var nextVer = inv.CurrentVersionNumber + 1;
|
||||
inv.CurrentBlobName = req.BlobName;
|
||||
inv.CurrentVersionNumber = nextVer;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
db.InventionVersions.Add(new InventionVersionEntity
|
||||
{
|
||||
InventionId = inv.Id,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
VersionNumber = nextVer,
|
||||
BlobName = req.BlobName,
|
||||
InstantiationCost = req.InstantiationCost ?? 0,
|
||||
LightsCost = req.LightsCost ?? 0,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
// ── GET-based mutations (Core.Get pattern) ───────────────────────────
|
||||
|
||||
/// <summary>Watch sends ONE of name/description/imgName/permission
|
||||
/// per request — `UpdateInvention*` ISIL builds different
|
||||
/// query strings. Pull whichever ones came in and apply.</summary>
|
||||
[HttpGet("api/inventions/v1/update")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Update(
|
||||
[FromQuery] long inventionId,
|
||||
[FromQuery] string? name,
|
||||
[FromQuery] string? description,
|
||||
[FromQuery] string? imgName,
|
||||
[FromQuery] int? permission)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name)) inv.Name = name.Trim();
|
||||
if (description is not null) inv.Description = description;
|
||||
if (imgName is not null) inv.ImageName = imgName;
|
||||
if (permission.HasValue)
|
||||
{
|
||||
inv.GeneralPermission = ClampInventionPermission(permission.Value);
|
||||
if (inv.GeneralPermission > 0 && !inv.IsPublished)
|
||||
{
|
||||
inv.IsPublished = true;
|
||||
inv.FirstPublishedAt = DateTime.UtcNow;
|
||||
}
|
||||
// Mirror legacy 0/1/2 column for back-compat.
|
||||
inv.Permission = inv.GeneralPermission >= 60 ? 2 : (inv.GeneralPermission >= 20 ? 1 : 0);
|
||||
}
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/delete")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Delete([FromQuery] long inventionId)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
inv.IsDeleted = true;
|
||||
inv.GeneralPermission = 0;
|
||||
inv.IsPublished = false;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v3/publish")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Publish(
|
||||
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
var perm = ClampInventionPermission(permissionLevel);
|
||||
if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)");
|
||||
inv.GeneralPermission = perm;
|
||||
inv.IsPublished = true;
|
||||
inv.FirstPublishedAt ??= DateTime.UtcNow;
|
||||
inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0);
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/unpublish")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Unpublish([FromQuery] long inventionId)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
inv.GeneralPermission = 0;
|
||||
inv.IsPublished = false;
|
||||
inv.Permission = 0;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/download")]
|
||||
public async Task<ActionResult> Download([FromQuery] long inventionId)
|
||||
{
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
inv.SpawnCount += 1;
|
||||
var pid = CurrentPlayerIdOrNull;
|
||||
if (pid is long me && me != inv.CreatorPlayerId)
|
||||
{
|
||||
// NumPlayersHaveUsedInRoom — increment per-distinct-player.
|
||||
// Approximate by distinct cheer/check; for now bump once
|
||||
// per download by non-creator.
|
||||
inv.NumPlayersHaveUsedInRoom += 1;
|
||||
}
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
// ── POST mutations ───────────────────────────────────────────────────
|
||||
|
||||
public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags);
|
||||
|
||||
[HttpPost("api/inventions/v1/settags")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> SetTags([FromBody] SetTagsRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(',');
|
||||
inv.TagsCsv = combined;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
public sealed record CheerRequest(long InventionId);
|
||||
|
||||
[HttpPost("api/inventions/v1/cheer")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Cheer([FromBody] CheerRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
|
||||
// Idempotent: one cheer per (player, invention).
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == pid && c.TargetInventionId == req.InventionId);
|
||||
if (existing is null)
|
||||
{
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
FromPlayerId = pid,
|
||||
TargetInventionId = req.InventionId,
|
||||
CheeredAt = DateTime.UtcNow,
|
||||
});
|
||||
inv.CheerCount += 1;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (inv.CreatorPlayerId != pid)
|
||||
await notifications.NotifyAsync(inv.CreatorPlayerId,
|
||||
PushNotificationId.InventionModerationStateChanged,
|
||||
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
|
||||
}
|
||||
return Ok(new { inv.Id, inv.CheerCount });
|
||||
}
|
||||
|
||||
public sealed record ReportInventionRequest(
|
||||
long InventionId, int? ReportCategory, string? Details);
|
||||
|
||||
[HttpPost("api/inventions/v1/report")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Report([FromBody] ReportInventionRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = pid,
|
||||
TargetPlayerId = inv.CreatorPlayerId,
|
||||
TargetInventionId = req.InventionId,
|
||||
Category = req.ReportCategory ?? 5 /* Other */,
|
||||
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Reported = true });
|
||||
}
|
||||
|
||||
// ── Wire serializers ─────────────────────────────────────────────────
|
||||
|
||||
private static int ClampInventionPermission(int v) => v switch
|
||||
{
|
||||
0 or 10 or 20 or 40 or 60 or 80 or 100 => v,
|
||||
_ => 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,
|
||||
ReplicationId = string.IsNullOrEmpty(i.ReplicationId)
|
||||
? Guid.Empty.ToString("D") : i.ReplicationId,
|
||||
// Wire field is int — IDs in this server are <2^31 so safe.
|
||||
CreatorPlayerId = (int)i.CreatorPlayerId,
|
||||
i.Name,
|
||||
i.Description,
|
||||
i.ImageName,
|
||||
i.CurrentVersionNumber,
|
||||
i.IsPublished,
|
||||
AllowTrial = true,
|
||||
ModifiedAt = i.UpdatedAt,
|
||||
i.CreatedAt,
|
||||
i.FirstPublishedAt,
|
||||
i.CreationRoomId,
|
||||
i.NumPlayersHaveUsedInRoom,
|
||||
NumDownloads = i.SpawnCount,
|
||||
i.CheerCount,
|
||||
i.CreatorPermission,
|
||||
i.GeneralPermission,
|
||||
IsAGInvention = i.IsAgInvention,
|
||||
Price = 0,
|
||||
HideFromPlayer = false,
|
||||
};
|
||||
|
||||
private static object ToWireV4(InventionEntity i) => ToWire(i);
|
||||
|
||||
private static object ToVersionWire(InventionVersionEntity v) => new
|
||||
{
|
||||
v.InventionId,
|
||||
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
|
||||
? Guid.Empty.ToString("D") : v.ReplicationId,
|
||||
v.VersionNumber,
|
||||
v.InstantiationCost,
|
||||
v.LightsCost,
|
||||
v.BlobName,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/{equipment,consumables}/* — backs the watch's
|
||||
/// Backpack tab. Wire DTOs match the decompiled client classes:
|
||||
///
|
||||
/// • <c>RecNet.Equipment</c> — fields: <c>PrefabName, ModificationGuid,
|
||||
/// PlatformMask, IsPlatformLocked, FriendlyName, Tooltip, Rarity,
|
||||
/// Favorited</c>.
|
||||
/// • <c>RecNet.Consumable</c> — fields: <c>Id(long), ConsumableType,
|
||||
/// PlatformMask, Count, InitialCount, UnlockedLevel, CreatedAt,
|
||||
/// ActiveDurationMinutes(int?), ConsumableItem(nested), Category,
|
||||
/// IsActive, IsPlatformLocked</c>.
|
||||
///
|
||||
/// Persistence: one <see cref="PlayerInventoryEntity"/> row per
|
||||
/// (player, item). For equipment, Quantity=1 + IsActive tracks
|
||||
/// favourite. For consumables, Quantity is the stack count + IsActive
|
||||
/// tracks "currently activated".
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class InventoryController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
// Categories the StoreItem table uses for equipment vs
|
||||
// consumables. The seed data tags items per these strings.
|
||||
private static readonly HashSet<string> EquipmentCategories = new()
|
||||
{
|
||||
"equipment", "tool", "maker_pen", "weapon",
|
||||
};
|
||||
private static readonly HashSet<string> ConsumableCategories = new()
|
||||
{
|
||||
"consumable",
|
||||
};
|
||||
|
||||
// ── Equipment ────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/equipment/v1/getUnlocked")]
|
||||
[HttpGet("api/equipment/v2/getUnlocked")]
|
||||
[HttpGet("api/equipment/v3/getUnlocked")]
|
||||
public async Task<IActionResult> GetUnlockedEquipment()
|
||||
{
|
||||
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
|
||||
&& EquipmentCategories.Contains(item.Category)
|
||||
select new { inv, item }).ToListAsync();
|
||||
return Ok(rows.Select(r => ToEquipmentWire(r.inv, r.item)));
|
||||
}
|
||||
|
||||
public sealed class EquipmentUpdateRequest
|
||||
{
|
||||
public string PrefabName { get; set; } = string.Empty;
|
||||
public string ModificationGuid { get; set; } = string.Empty;
|
||||
public bool Favorited { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
|
||||
/// "favorited" flag on a piece of equipment (the watch lets the
|
||||
/// player pin commonly-used tools).</summary>
|
||||
[HttpPost("api/equipment/v1/update")]
|
||||
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
|
||||
{
|
||||
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 row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
|
||||
if (row is null && !string.IsNullOrWhiteSpace(req.ModificationGuid))
|
||||
{
|
||||
// Legacy rows created before catalog-backed equipment skins
|
||||
// used the raw modification GUID as ItemSlug.
|
||||
row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.ModificationGuid);
|
||||
}
|
||||
if (row is null && !string.IsNullOrWhiteSpace(req.PrefabName))
|
||||
{
|
||||
row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.PrefabName);
|
||||
}
|
||||
if (row is null)
|
||||
{
|
||||
// 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()
|
||||
{
|
||||
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();
|
||||
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; }
|
||||
public int DeltaCount { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/consumables/v1/consume")]
|
||||
public async Task<IActionResult> Consume([FromBody] ConsumableItemRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id);
|
||||
if (row is null) return NotFound();
|
||||
var delta = Math.Max(1, Math.Abs(req.DeltaCount));
|
||||
row.Quantity = Math.Max(0, row.Quantity - delta);
|
||||
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.NotifyAsync(pid,
|
||||
row.Quantity > 0
|
||||
? PushNotificationId.ConsumableMappingAdded
|
||||
: PushNotificationId.ConsumableMappingRemoved,
|
||||
new { row.Id, row.ItemSlug, row.Quantity });
|
||||
return Ok(new { row.Id, Count = row.Quantity });
|
||||
}
|
||||
|
||||
public sealed class ActivateConsumableRequest
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/consumables/v1/updateActive")]
|
||||
public async Task<IActionResult> UpdateActive([FromBody] ActivateConsumableRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id);
|
||||
if (row is null) return NotFound();
|
||||
row.IsActive = req.IsActive;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { row.Id, row.IsActive });
|
||||
}
|
||||
|
||||
// ── Wire serializers ─────────────────────────────────────────────────
|
||||
|
||||
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item)
|
||||
{
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
|
||||
{
|
||||
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
|
||||
? consumableDesc
|
||||
: item.Slug;
|
||||
var category = StoreService.TryGetHairDyePayload(item.Slug, out _)
|
||||
? 6
|
||||
: 0;
|
||||
|
||||
return new
|
||||
{
|
||||
inv.Id,
|
||||
ConsumableItemDesc = type,
|
||||
ConsumableType = type,
|
||||
PlatformMask = -1,
|
||||
Count = inv.Quantity,
|
||||
InitialCount = inv.Quantity, // we don't track original purchase count
|
||||
UnlockedLevel = 0,
|
||||
CreatedAt = inv.AcquiredAt,
|
||||
ActiveDurationMinutes = (int?)null,
|
||||
ConsumableItem = new
|
||||
{
|
||||
Id = inv.Id,
|
||||
Type = type,
|
||||
FriendlyName = item.DisplayName,
|
||||
Tooltip = item.Description,
|
||||
ImageName = item.ImageName,
|
||||
},
|
||||
Category = category,
|
||||
inv.IsActive,
|
||||
IsPlatformLocked = false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Leaderboard;
|
||||
|
||||
/// <summary>
|
||||
/// leaderboard.{rec.net,localhost}/leaderboard/* — per-stat-channel
|
||||
/// leaderboard reads + writes. URLs verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:1934, 2416, 2700</c>:
|
||||
///
|
||||
/// POST leaderboard/SetStat body: SetStatRequestDTO
|
||||
/// <c>{StatChannel(int), RoomId(long), StatValue(int)}</c>
|
||||
/// POST leaderboard/GetPlayerRank body: GetPlayerRankData
|
||||
/// <c>{PlayerId(int), StatChannel(int), RoomId(long)}</c>
|
||||
///
|
||||
/// **Subdomain matters**: the client uses
|
||||
/// <c>Core.SendRequest(method, Service.Leaderboard, requestUri)</c>
|
||||
/// where <c>Service.Leaderboard = 9</c> resolves via the
|
||||
/// <c>RecRoomConfig.ServiceUrls</c> map (we serve via
|
||||
/// <c>api/config/v2</c>) to <c>https://leaderboard.{apex}</c>. So
|
||||
/// the actual outbound URL is <c>leaderboard.localhost/leaderboard/SetStat</c>
|
||||
/// — not <c>api.localhost/api/leaderboard/...</c>.
|
||||
///
|
||||
/// Response shapes:
|
||||
/// SetStat → RequestResult ack <c>{success:true,error:""}</c>
|
||||
/// GetPlayerRank → FullLeaderboard.Entry — <b>camelCase</b> per
|
||||
/// <c>FullLeaderboard_NestedType_Entry.txt:111-121</c>:
|
||||
/// <c>{playerId(int), score(long), rank(int)}</c>.
|
||||
///
|
||||
/// Without explicit subdomain routing the request fell through to
|
||||
/// <see cref="GlobalCatchAllController"/> which returns <c>[]</c>;
|
||||
/// the client's <c>Util.Deserialize<Entry></c> tried to cast
|
||||
/// list-as-dictionary →
|
||||
/// <c>InvalidCastException: Unable to cast 'List' to 'Dictionary'</c>
|
||||
/// → "Failed to retrieve leaderboard: Malformed Response".
|
||||
///
|
||||
/// We bind both the leaderboard subdomain (canonical) AND the api
|
||||
/// host (defensive — some 2020 builds may proxy via api/) so either
|
||||
/// path lands here.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class LeaderboardController(
|
||||
DorkNetDbContext db,
|
||||
PlayerPresenceService playerPresence) : ControllerBase
|
||||
{
|
||||
public sealed class SetStatRequest
|
||||
{
|
||||
public int StatChannel { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int StatValue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/leaderboard/SetStat</c> on the leaderboard
|
||||
/// subdomain (canonical client path) AND <c>/api/leaderboard/SetStat</c>
|
||||
/// on the api host (legacy / proxy fallback).
|
||||
///
|
||||
/// **Wire semantics**: the watch's
|
||||
/// <c>Leaderboards.ConditionalSetStat_ReturningNewValue</c>
|
||||
/// (Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:645) handles the
|
||||
/// per-channel direction (high-score vs low-time-better) LOCALLY
|
||||
/// via a SetMode enum and only POSTs to <c>SetStat</c> when the
|
||||
/// new value should replace the cached one. The wire request
|
||||
/// itself carries no direction hint — it's just
|
||||
/// <c>{StatChannel, RoomId, StatValue}</c>. So the server must
|
||||
/// trust the client and assign unconditionally; an earlier
|
||||
/// <c>Math.Max</c> here silently kept the LARGEST value, which
|
||||
/// broke channels like StuntRunner (channel 7, low elapsed time
|
||||
/// wins) where the watch posts the new best and we'd discard it
|
||||
/// for any prior worse value.</summary>
|
||||
[HttpPost("/leaderboard/SetStat")]
|
||||
[HttpPost("/api/leaderboard/SetStat")]
|
||||
[Authorize]
|
||||
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);
|
||||
if (row is null)
|
||||
{
|
||||
row = new LeaderboardStatEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
RoomId = roomId,
|
||||
StatChannel = req.StatChannel,
|
||||
Value = req.StatValue,
|
||||
};
|
||||
db.LeaderboardStats.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = req.StatValue;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Auto-attach the channel to whichever room the score came
|
||||
// from so every room implicitly gets its own leaderboards.
|
||||
// The watch's <c>SetStatRequestDTO</c> carries
|
||||
// <c>RoomId</c> (see Cpp2IL_ISIL .../SetStatRequestDTO);
|
||||
// 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.
|
||||
if (roomId > 0)
|
||||
{
|
||||
var meta = await db.LeaderboardChannelMeta
|
||||
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == req.StatChannel);
|
||||
if (meta is null)
|
||||
{
|
||||
// First time this channel reports. Stamp a meta row so
|
||||
// the admin SPA's per-room Leaderboards tab surfaces it
|
||||
// immediately, with a placeholder name the admin can
|
||||
// rename later. Direction defaults to higher-is-better;
|
||||
// admin flips the toggle for time-trial channels.
|
||||
db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity
|
||||
{
|
||||
Channel = req.StatChannel,
|
||||
RoomId = roomId,
|
||||
Name = $"Channel {req.StatChannel}",
|
||||
LowerIsBetter = false,
|
||||
ValueFormat = "count",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
public sealed class GetPlayerRankRequest
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
public int StatChannel { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("/leaderboard/GetPlayerRank")]
|
||||
[HttpPost("/api/leaderboard/GetPlayerRank")]
|
||||
public async Task<IActionResult> GetPlayerRank([FromBody] GetPlayerRankRequest req)
|
||||
{
|
||||
var pid = req.PlayerId > 0
|
||||
? (long)req.PlayerId
|
||||
: this.CurrentPlayerId() ?? 0;
|
||||
// Empty/no-row case: return a zero entry. The client tolerates
|
||||
// this — `Entry.Deserialize` reads playerId/score/rank as
|
||||
// primitives with defaults.
|
||||
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);
|
||||
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));
|
||||
return Ok(new
|
||||
{
|
||||
playerId = (int)pid,
|
||||
score = row.Value,
|
||||
rank = betterCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class GetNearbyScoresRequest
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
public int StatChannel { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int WindowSize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/leaderboard/GetNearbyScores</c> — returns the
|
||||
/// N entries on either side of the caller's rank. Wire shape per
|
||||
/// <c>GetNearbyScoresRequestDTO.cs</c>: PlayerId/StatChannel/RoomId
|
||||
/// inherited from GetRankRequestDTO + WindowSize.</summary>
|
||||
[HttpPost("/leaderboard/GetNearbyScores")]
|
||||
[HttpPost("/api/leaderboard/GetNearbyScores")]
|
||||
public async Task<IActionResult> GetNearbyScores([FromBody] GetNearbyScoresRequest req)
|
||||
{
|
||||
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();
|
||||
if (all.Count == 0)
|
||||
{
|
||||
// Empty case must still come back as a SingleLeaderboard
|
||||
// Dictionary, not a bare array — the 2020 watch's
|
||||
// SingleLeaderboard.Deserialize reads dict["rows"]
|
||||
// (Cpp2IL_ISIL/.../RecNet/SingleLeaderboard.txt:60).
|
||||
// Returning `[]` cast-fails as List→Dictionary inside
|
||||
// RecNet.Util.Deserialize, logged repeatedly as
|
||||
// "Received malformed RecNet response: '[]'" and
|
||||
// eventually starves the StuntRunner UI of a usable
|
||||
// leaderboard.
|
||||
return Ok(new { rows = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
var ranked = all.Select((r, i) => new
|
||||
{
|
||||
playerId = (int)r.PlayerId,
|
||||
score = r.Value,
|
||||
rank = i + 1,
|
||||
}).ToList();
|
||||
|
||||
var myIdx = ranked.FindIndex(r => r.playerId == (int)pid);
|
||||
if (myIdx < 0)
|
||||
{
|
||||
return Ok(new { rows = ranked.Take(window * 2 + 1).ToArray() });
|
||||
}
|
||||
var lo = Math.Max(0, myIdx - window);
|
||||
var hi = Math.Min(ranked.Count - 1, myIdx + window);
|
||||
return Ok(new { rows = ranked.GetRange(lo, hi - lo + 1).ToArray() });
|
||||
}
|
||||
|
||||
public sealed class GetRanksRequest
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
public int StatChannel { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int RankStart { get; set; }
|
||||
public int RankEnd { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/leaderboard/GetRanks</c> — returns a slice of
|
||||
/// the leaderboard between RankStart and RankEnd (inclusive,
|
||||
/// 1-indexed). Wire shape per <c>GetRanksRequestDTO.cs</c>.</summary>
|
||||
[HttpPost("/leaderboard/GetRanks")]
|
||||
[HttpPost("/api/leaderboard/GetRanks")]
|
||||
public async Task<IActionResult> GetRanks([FromBody] GetRanksRequest req)
|
||||
{
|
||||
var start = Math.Max(1, req.RankStart);
|
||||
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 entries = rows.Select((s, i) => new
|
||||
{
|
||||
playerId = (int)s.PlayerId,
|
||||
score = s.Value,
|
||||
rank = start + i,
|
||||
}).ToArray();
|
||||
// SingleLeaderboard wrapper — see GetNearbyScores above.
|
||||
return Ok(new { rows = entries });
|
||||
}
|
||||
|
||||
/// <summary>Top-N for a channel. Used by the admin tooling and
|
||||
/// 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)
|
||||
{
|
||||
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();
|
||||
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
|
||||
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
|
||||
// same Entry list for all four buckets — friends-vs-global
|
||||
// can come later.
|
||||
var entries = rows.Select((s, i) => new
|
||||
{
|
||||
playerId = (int)s.PlayerId,
|
||||
score = s.Value,
|
||||
rank = i + 1,
|
||||
}).ToArray();
|
||||
return Ok(new
|
||||
{
|
||||
GlobalOverall = entries,
|
||||
GlobalPeriodic = entries,
|
||||
FriendsOverall = Array.Empty<object>(),
|
||||
FriendsPeriodic = Array.Empty<object>(),
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Messages.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/messages/v2/* — direct-message inbox API. Backed
|
||||
/// by <see cref="MessageEntity"/>. Sends fan out a SignalR push
|
||||
/// (<c>MessageReceived</c>) so the recipient's watch refreshes
|
||||
/// immediately.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
[Authorize]
|
||||
public class MessagesController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
PrivateInstanceService privateInstances,
|
||||
PlayerPresenceService presence,
|
||||
ServerSettingsService serverSettings,
|
||||
ILogger<MessagesController> logger) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
/// <summary>List the caller's inbox, newest-first. Response is a
|
||||
/// flat array of <see cref="MessageDto"/>; the watch groups by
|
||||
/// sender locally. v1/v3 paths share the same handler — the watch
|
||||
/// uses whichever URL its <c>RecNet.Messages</c> static helper
|
||||
/// built from <c>GameConfig.MessageServiceVersion</c>.</summary>
|
||||
[HttpGet("get")]
|
||||
[HttpGet("/api/messages/v1/get")]
|
||||
[HttpGet("/api/messages/v3/get")]
|
||||
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)
|
||||
// as "Message : {body}", so an empty body becomes a
|
||||
// useless "Message :" entry the user has to scroll past.
|
||||
// Control-channel types (10 RequestGameInvite, 20
|
||||
// FriendStatusOnline, etc.) have their own labels and
|
||||
// legitimately carry no body, so we keep them.
|
||||
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(await ToDtosAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/messages/v1/unread</c> — list of unread
|
||||
/// messages addressed to the caller. The watch uses the count for
|
||||
/// the inbox badge and the entries themselves for the toast
|
||||
/// preview.</summary>
|
||||
[HttpGet("/api/messages/v1/unread")]
|
||||
public async Task<ActionResult<List<MessageDto>>> Unread()
|
||||
{
|
||||
var rows = await db.Messages
|
||||
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null && m.Type >= 0)
|
||||
// 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 != ""))
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(await ToDtosAsync(rows));
|
||||
}
|
||||
|
||||
public sealed class DeleteRequest
|
||||
{
|
||||
public long? Id { get; set; }
|
||||
public long? MessageId { get; set; }
|
||||
public List<long>? MessageIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v3/delete</c> — 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
|
||||
/// <c>{"MessageIds":[8]}</c> against <c>/api/messages/v3/delete</c>);
|
||||
/// 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>
|
||||
[HttpPost("/api/messages/v3/delete")]
|
||||
[HttpPost("/api/messages/v1/delete")]
|
||||
[Consumes("application/json")]
|
||||
public async Task<IActionResult> DeleteJson([FromBody] DeleteRequest body)
|
||||
{
|
||||
var ids = new List<long>();
|
||||
if (body.Id is long a && a > 0) ids.Add(a);
|
||||
if (body.MessageId is long b && b > 0) ids.Add(b);
|
||||
if (body.MessageIds is { Count: > 0 })
|
||||
ids.AddRange(body.MessageIds.Where(v => v > 0));
|
||||
return await DeleteByIds(ids);
|
||||
}
|
||||
|
||||
[HttpPost("/api/messages/v3/delete")]
|
||||
[HttpPost("/api/messages/v1/delete")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> DeleteForm(
|
||||
[FromForm(Name = "id")] long? formId,
|
||||
[FromForm(Name = "messageId")] long? formMessageId,
|
||||
[FromForm(Name = "messageIds")] string? messageIdsCsv)
|
||||
{
|
||||
var ids = new List<long>();
|
||||
if (formId is long a && a > 0) ids.Add(a);
|
||||
if (formMessageId is long b && b > 0) ids.Add(b);
|
||||
if (long.TryParse(Request.Query["id"], out var qi) && qi > 0) ids.Add(qi);
|
||||
if (!string.IsNullOrWhiteSpace(messageIdsCsv))
|
||||
{
|
||||
foreach (var s in messageIdsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
if (long.TryParse(s.Trim(), out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
return await DeleteByIds(ids);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> DeleteByIds(List<long> ids)
|
||||
{
|
||||
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
|
||||
.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 });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v3/markRead</c> — bulk
|
||||
/// mark-as-read for every unread message addressed to the caller.
|
||||
/// Optional <c>ids</c> CSV scopes to specific message ids.</summary>
|
||||
[HttpPost("/api/messages/v3/markRead")]
|
||||
[HttpPost("/api/messages/v1/markRead")]
|
||||
public async Task<IActionResult> MarkReadBulk([FromForm(Name = "ids")] string? ids)
|
||||
{
|
||||
IQueryable<MessageEntity> q = db.Messages
|
||||
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null);
|
||||
if (!string.IsNullOrWhiteSpace(ids))
|
||||
{
|
||||
var idSet = ids
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||
.Where(v => v > 0)
|
||||
.ToHashSet();
|
||||
if (idSet.Count > 0) q = q.Where(m => idSet.Contains(m.Id));
|
||||
}
|
||||
var now = DateTime.UtcNow;
|
||||
await q.ExecuteUpdateAsync(s => s.SetProperty(m => m.ReadAt, now));
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
public sealed record SendRequest(long RecipientId, string Body, long? RoomId = null);
|
||||
|
||||
/// <summary>Send a direct message — JSON wire shape used by some
|
||||
/// SPA/admin callers. The 2020 watch posts to the same path with
|
||||
/// form-urlencoded fields (<see cref="SendFormMessage"/>); the
|
||||
/// <c>[Consumes]</c> attribute disambiguates which action MVC
|
||||
/// picks. Empty bodies are rejected so the inbox doesn't get
|
||||
/// padded with whitespace pings; messages over 2000 chars are
|
||||
/// truncated to fit the column.</summary>
|
||||
[HttpPost("send")]
|
||||
[HttpPost("/api/messages/v1/send")]
|
||||
[Consumes("application/json")]
|
||||
public async Task<ActionResult<MessageDto>> SendMessage([FromBody] SendRequest req)
|
||||
{
|
||||
if (req.RecipientId <= 0 || req.RecipientId == Me)
|
||||
return BadRequest(new { error = "invalid_recipient" });
|
||||
var trimmed = (req.Body ?? string.Empty).Trim();
|
||||
if (trimmed.Length == 0) return BadRequest(new { error = "empty_body" });
|
||||
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
|
||||
|
||||
var entry = new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = req.RecipientId,
|
||||
Body = trimmed,
|
||||
Type = 30, // TextMessage
|
||||
RoomId = req.RoomId,
|
||||
};
|
||||
db.Messages.Add(entry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await BroadcastMessageAsync(entry);
|
||||
return Ok(ToDto(entry));
|
||||
}
|
||||
|
||||
/// <summary>Form-urlencoded variant — what the 2020 watch actually
|
||||
/// posts when the user taps "Send Join Request" or any other
|
||||
/// <c>RecNet.Messages.SendMessage(player, type, data)</c> caller.
|
||||
/// Wire shape (verified against client log <c>contentType=
|
||||
/// application/x-www-form-urlencoded</c>, body <c>ToPlayerId=…&
|
||||
/// Type=10&Data=</c>): the <c>Type</c> integer is a
|
||||
/// <c>RecNet.Message.MessageType</c> enum value
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/Message.cs</c>) — 10 =
|
||||
/// RequestGameInvite, 30 = TextMessage, etc. Empty <c>Data</c> is
|
||||
/// allowed for control-channel types (join request, declines)
|
||||
/// where the message itself is the signal.</summary>
|
||||
[HttpPost("send")]
|
||||
[HttpPost("/api/messages/v1/send")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<MessageDto>> SendFormMessage(
|
||||
[FromForm(Name = "ToPlayerId")] long toPlayerId,
|
||||
[FromForm(Name = "Type")] int type,
|
||||
[FromForm(Name = "Data")] string? data,
|
||||
[FromForm(Name = "RoomId")] long? roomId)
|
||||
{
|
||||
if (toPlayerId <= 0 || toPlayerId == Me)
|
||||
return BadRequest(new { error = "invalid_recipient" });
|
||||
var trimmed = (data ?? string.Empty);
|
||||
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
|
||||
|
||||
// For TextMessage (type 30) the body IS the message — empty
|
||||
// is meaningless and renders as a ghost "Message :" row in
|
||||
// the watch's inbox (MessagesScreenItem format string at
|
||||
// Cpp2IL_ISIL/.../MessagesScreenItem.txt:2145 is
|
||||
// "Message : {0}"). Control-channel types (RequestGameInvite,
|
||||
// declines, FriendStatusOnline, etc.) intentionally carry
|
||||
// empty Data — they get rendered by their own label string
|
||||
// in the same screen item.
|
||||
if (type == 30 && trimmed.Trim().Length == 0)
|
||||
return BadRequest(new { error = "empty_text_message" });
|
||||
|
||||
var entry = new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = toPlayerId,
|
||||
Body = trimmed,
|
||||
Type = type,
|
||||
RoomId = roomId,
|
||||
};
|
||||
db.Messages.Add(entry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"[messages] send from={From} to={To} type={Type} dataLen={Len}",
|
||||
Me, toPlayerId, type, trimmed.Length);
|
||||
|
||||
await BroadcastMessageAsync(entry);
|
||||
return Ok(ToDto(entry));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>invite</c> — handles the watch's
|
||||
/// <c>RecNet.Messages.SendGameInvite(playerId, roomInstanceId)</c>
|
||||
/// (verified at <c>Cpp2IL_ISIL/.../Messages.txt:613-773</c>): form
|
||||
/// fields <c>playerId</c> (recipient) + <c>roomInstanceId</c>
|
||||
/// (the inviter's private match id). Two side-effects:
|
||||
/// 1. Add the recipient to the invite list for that instance so
|
||||
/// <c>POST /goto/instance/{id}</c> accepts them.
|
||||
/// 2. Drop a Type-6 (GameInviteV2) message in their inbox carrying the
|
||||
/// RoomInstanceId so the message UI's "Join" button knows
|
||||
/// where to send them.
|
||||
/// Registered at multiple paths because the exact URL the watch
|
||||
/// builds for "invite" depends on the messages-namespace base —
|
||||
/// rather than guess wrong and silently fall through to the
|
||||
/// wildcard, we cover all plausible mounts and log the one that
|
||||
/// actually fires.</summary>
|
||||
[HttpPost("/invite")]
|
||||
[HttpPost("/api/invite")]
|
||||
[HttpPost("/api/messages/v1/invite")]
|
||||
[HttpPost("/api/messages/v2/invite")]
|
||||
[HttpPost("/api/messages/v3/invite")]
|
||||
public async Task<ActionResult> SendInvite(
|
||||
[FromForm(Name = "playerId")] long playerId,
|
||||
[FromForm(Name = "roomInstanceId")] long roomInstanceId)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[invite] from={From} → to={To} instance={Instance} (path={Path})",
|
||||
Me, playerId, roomInstanceId, Request.Path);
|
||||
|
||||
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;
|
||||
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";
|
||||
}
|
||||
|
||||
// Persist a real RecNet.Message.MessageType.GameInviteV2 (=6)
|
||||
// so the watch's GameInviteController calls
|
||||
// Matchmaking.AcceptRoomInvite(inviteId, roomId), which posts
|
||||
// goto/invite/{inviteId}. Type 0 follows the sender's current
|
||||
// presence instead and can land in the wrong instance.
|
||||
//
|
||||
// Two saves: the first allocates the message Id (which becomes
|
||||
// the `inviteId` the watch sends back in /goto/invite/{N}); the
|
||||
// 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 entry = new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = playerId,
|
||||
// Placeholder so the row passes the NOT NULL constraint
|
||||
// until we overwrite it post-save with the real JSON.
|
||||
Body = string.Empty,
|
||||
Type = 6,
|
||||
RoomId = resolvedRoomId,
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
// Body shape MUST be the JSON the watch's Message.Deserialize
|
||||
// builds RoomInviteDetails from (verified at
|
||||
// Cpp2IL_ISIL/.../RecNet/Message.txt:786-805 — for Type 6/7
|
||||
// the parser hits `Json.Deserialize` and reads "inviteId" +
|
||||
// "name"; for other types it `Int32.Parse`s the body).
|
||||
//
|
||||
// inviteId → the message Id; watch echoes it back in
|
||||
// /goto/invite/{inviteId}, server looks the
|
||||
// row up to resolve roomInstanceId.
|
||||
// name → display string in the invite toast / row.
|
||||
// roomInstanceId → extra field the watch ignores; server uses
|
||||
// it (via TryParseRoomInstanceId's JSON
|
||||
// fallback) so /goto/invite/{N} doesn't have
|
||||
// to re-query the private-instance service.
|
||||
//
|
||||
// The roomInstanceId MUST be a JSON STRING, not a number. LitJson
|
||||
// chokes on the dict-cast when a value is a 64-bit integer larger
|
||||
// than int32 — confirmed via SaveDebugMod trace: the watch logs
|
||||
// `data={"inviteId":31,"name":"Dorm_1811750","roomInstanceId":6546545006375796736}`
|
||||
// `RoomInviteDetails=<null>`
|
||||
// `System.NullReferenceException at RecNet.Util.GetKey from
|
||||
// RecNet.Message.Deserialize`
|
||||
// Json.Deserialize returns a null cast-result on the long value,
|
||||
// GetKey("inviteId") then NREs and the whole parse aborts before
|
||||
// RoomInviteDetails is built. Stringifying the value sidesteps
|
||||
// LitJson's long-int handling entirely; the watch only reads
|
||||
// inviteId + name so the string form is invisible to it.
|
||||
entry.Body = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
inviteId = entry.Id,
|
||||
name = roomName,
|
||||
roomInstanceId = roomInstanceId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await BroadcastMessageAsync(entry);
|
||||
return Ok(new { success = true, messageId = entry.Id });
|
||||
}
|
||||
|
||||
/// <summary>Mark a single message as read. No-op if not
|
||||
/// addressed to the caller — prevents the sender from "reading"
|
||||
/// the recipient's copy.</summary>
|
||||
[HttpPost("markread/{id:long}")]
|
||||
public async Task<ActionResult> MarkRead(long id)
|
||||
{
|
||||
var msg = await db.Messages.FirstOrDefaultAsync(m =>
|
||||
m.Id == id && m.RecipientPlayerId == Me);
|
||||
if (msg is null) return NotFound();
|
||||
if (msg.ReadAt is null)
|
||||
{
|
||||
msg.ReadAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
public sealed class SendMultipleBody
|
||||
{
|
||||
public List<long>? ToPlayerIds { get; set; }
|
||||
public int Type { get; set; }
|
||||
public string? Data { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v1/sendMultiple</c> — fan-out a
|
||||
/// message to many recipients. Persists one MessageEntity per
|
||||
/// recipient. Wire shape: <c>{ToPlayerIds (int[]), Type (int), Data (string)}</c>.</summary>
|
||||
[HttpPost("/api/messages/v1/sendMultiple")]
|
||||
[Consumes("application/json")]
|
||||
public async Task<IActionResult> SendMultiple([FromBody] SendMultipleBody body)
|
||||
{
|
||||
if (body.ToPlayerIds is null || body.ToPlayerIds.Count == 0)
|
||||
return Ok(Array.Empty<object>());
|
||||
var entries = new List<MessageEntity>();
|
||||
foreach (var to in body.ToPlayerIds.Distinct().Where(id => id > 0 && id != Me))
|
||||
{
|
||||
var entry = new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = to,
|
||||
Body = body.Data ?? string.Empty,
|
||||
Type = body.Type,
|
||||
RoomId = body.RoomId,
|
||||
};
|
||||
entries.Add(entry);
|
||||
db.Messages.Add(entry);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
await BroadcastMessageAsync(entry);
|
||||
}
|
||||
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> +
|
||||
/// <c>Data</c>. Idempotent per (sender, recipient, body).</summary>
|
||||
[HttpPost("/api/offlineinvite/v1/send")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> OfflineInviteSend(
|
||||
[FromForm(Name = "RecipientId")] long? recipientId,
|
||||
[FromForm(Name = "Data")] string? data)
|
||||
{
|
||||
if (recipientId is not long rid || rid <= 0 || rid == Me)
|
||||
return Ok(new { success = false, error = "invalid_recipient" });
|
||||
var entry = new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = rid,
|
||||
Body = data ?? string.Empty,
|
||||
Type = 0,
|
||||
};
|
||||
db.Messages.Add(entry);
|
||||
await db.SaveChangesAsync();
|
||||
await BroadcastMessageAsync(entry);
|
||||
return Ok(new { success = true, messageId = entry.Id });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/offlineinvite/v1/all</c> — invites
|
||||
/// received while the player was offline. Pulls unread
|
||||
/// MessageEntity rows whose body looks like an invite.</summary>
|
||||
[HttpGet("/api/offlineinvite/v1/all")]
|
||||
public async Task<IActionResult> OfflineInviteAll()
|
||||
{
|
||||
var rows = await db.Messages
|
||||
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null
|
||||
&& (m.Body.Contains("invite") || m.Body.Contains("event")))
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Take(50)
|
||||
.Select(m => new
|
||||
{
|
||||
InviteId = m.Id,
|
||||
FromPlayerId = (int)m.SenderPlayerId,
|
||||
m.Body,
|
||||
SentAt = m.SentAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
[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);
|
||||
return Ok(friendIds.Select(id => new
|
||||
{
|
||||
AccountId = id,
|
||||
Online = presence.IsRecentlyActive(id),
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Wire shape verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Message.txt:591-787</c>.
|
||||
/// <c>RecNet.Message.Deserialize</c> calls <c>Util.GetKey</c>
|
||||
/// (throws on miss) for: <c>Id</c> (long), <c>FromPlayerId</c> (int),
|
||||
/// <c>SentTime</c> (DateTime), <c>Type</c> (int — MessageType enum),
|
||||
/// and reads <c>Data</c> (string) via <c>Util.GetKeyOrDefault</c>.
|
||||
/// Optional nullable wrappers: <c>RoomId</c>, <c>PlayerEventId</c>.
|
||||
/// Previous response used <c>{Id, Sender, Recipient, Body, SentAt, ReadAt}</c>
|
||||
/// which crashed at <c>Util.GetKey("FromPlayerId")</c>.</summary>
|
||||
public sealed record MessageDto(
|
||||
long Id,
|
||||
long FromPlayerId,
|
||||
DateTime SentTime,
|
||||
int Type,
|
||||
string Data,
|
||||
long? RoomId,
|
||||
long? PlayerEventId);
|
||||
|
||||
private async Task<List<MessageDto>> ToDtosAsync(List<MessageEntity> rows)
|
||||
{
|
||||
var instanceIds = rows
|
||||
.Where(m => IsInstanceInviteType(m.Type)
|
||||
&& m.RoomId is null
|
||||
&& long.TryParse(m.Body, System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||
.Select(m => long.Parse(m.Body, System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var roomByInstance = instanceIds.Count == 0
|
||||
? new Dictionary<long, long>()
|
||||
: await db.PrivateInstances
|
||||
.Where(i => instanceIds.Contains(i.Id))
|
||||
.Select(i => new { i.Id, i.RoomId })
|
||||
.ToDictionaryAsync(i => i.Id, i => i.RoomId);
|
||||
|
||||
return rows.Select(m =>
|
||||
{
|
||||
long? roomId = m.RoomId;
|
||||
if (roomId is null
|
||||
&& IsInstanceInviteType(m.Type)
|
||||
&& long.TryParse(m.Body, System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var instanceId)
|
||||
&& roomByInstance.TryGetValue(instanceId, out var rid))
|
||||
{
|
||||
roomId = rid;
|
||||
}
|
||||
return ToDto(m, roomId);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static bool IsInstanceInviteType(int type) => type is 6 or 7;
|
||||
|
||||
/// <summary>Push a <c>MessageReceived</c> SignalR notification to BOTH
|
||||
/// the recipient AND the sender. The recipient half is the obvious one
|
||||
/// — that's what fires the watch's <c>OnMessageAdded</c> event and
|
||||
/// gives them the toast / inbox-row update. The sender-half is less
|
||||
/// obvious but equally needed: the watch's <c>Messages.SendMessage</c>
|
||||
/// (Messages.txt:1790-1900) just does <c>ExpectHttpStatusSuccess</c>
|
||||
/// on the response and never touches the local <c>MessageList</c>,
|
||||
/// so without a self-targeted push the sender's chat thread doesn't
|
||||
/// know about its own outgoing message until the next
|
||||
/// <c>RefreshList</c> (which only fires on hub-reconnect or first
|
||||
/// inbox open). Pushing to both sides keeps both watches in sync
|
||||
/// in real time. Dedup happens client-side in
|
||||
/// <c>OnMessageReceived</c> via the "already-in-MessageList by Id"
|
||||
/// check (Messages.txt:2790-2793), so a player who somehow ends up
|
||||
/// as both sender and recipient (admin self-messaging) only sees
|
||||
/// the message once.</summary>
|
||||
private async Task BroadcastMessageAsync(MessageEntity entry)
|
||||
{
|
||||
var dto = ToDto(entry);
|
||||
await notifications.NotifyAsync(entry.RecipientPlayerId,
|
||||
PushNotificationId.MessageReceived, dto);
|
||||
if (entry.SenderPlayerId != entry.RecipientPlayerId)
|
||||
{
|
||||
await notifications.NotifyAsync(entry.SenderPlayerId,
|
||||
PushNotificationId.MessageReceived, dto);
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageDto ToDto(MessageEntity m, long? roomId = null) =>
|
||||
new(
|
||||
Id: m.Id,
|
||||
FromPlayerId: m.SenderPlayerId,
|
||||
SentTime: m.SentAt,
|
||||
// Round-trip the stored Type verbatim. Earlier we coerced
|
||||
// Type==0 → 30 for "pre-Type-column rows", but the
|
||||
// Messages.Type Postgres patch (Program.cs:612-614) added
|
||||
// the column with DEFAULT 30 and backfilled every legacy
|
||||
// row to 30, so any Type==0 row in the DB now is a real
|
||||
// GameInvite (e.g. SendInvite above). Coercing it to 30
|
||||
// routes it through TextMessageController and renders
|
||||
// "Message : <instanceId>" with no Accept button.
|
||||
Type: m.Type,
|
||||
Data: m.Body,
|
||||
RoomId: roomId ?? m.RoomId,
|
||||
PlayerEventId: null);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Moderation;
|
||||
|
||||
/// <summary>
|
||||
/// In-game moderation endpoints invoked by the watch's player-action
|
||||
/// dialogs. URLs / form-fields verified against the Cpp2IL ISIL dump:
|
||||
///
|
||||
/// POST api/PlayerReporting/v3/create
|
||||
/// PlayerReporting.txt:1696 + 1712-1822
|
||||
/// Form: PlayerIdReported (int), ReportCategory (int), Details (string),
|
||||
/// HeightReporter (float "F2"), HeightReported (float "F2"),
|
||||
/// RoomId (long).
|
||||
///
|
||||
/// POST api/PlayerReporting/v3/voteToKick
|
||||
/// PlayerReporting.txt:3175 + 3215-3237
|
||||
/// Form: PlayerId (long), Response (bool), GameSessionId (long).
|
||||
///
|
||||
/// POST api/PlayerReporting/v1/instantKick
|
||||
/// PlayerReporting.txt:3417 (single) + 3590 (bulk)
|
||||
/// Body: PlayerId(s) — one or many. Admin/dev only.
|
||||
///
|
||||
/// POST api/PlayerReporting/v1/deviceId
|
||||
/// PlayerReporting.txt:3736 + 3749
|
||||
/// Form: oldDeviceId (string).
|
||||
///
|
||||
/// POST api/PlayersBanned/v2/ban
|
||||
/// Moderation.txt:623-702
|
||||
/// Form: PlayerId (long), Reason (int), BanType (int),
|
||||
/// DisplayReason (string), BannedUntil (string MM/dd/yyyy HH:mm).
|
||||
/// Admin only.
|
||||
///
|
||||
/// Without these handlers the watch fell through to the JSON catch-all
|
||||
/// returning <c>{}</c>, which the OkResponse parser tolerates but the
|
||||
/// admin-action then has no observable effect — i.e. "the admin
|
||||
/// system doesn't work at all".
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class InGameModerationController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
ILogger<InGameModerationController> logger) : ControllerBase
|
||||
{
|
||||
// ── /v3/create — file a player report ────────────────────────────
|
||||
[HttpPost("api/PlayerReporting/v3/create")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> CreateReport(
|
||||
[FromForm(Name = "PlayerIdReported")] long? playerIdReported,
|
||||
[FromForm(Name = "ReportCategory")] int reportCategory = 0,
|
||||
[FromForm(Name = "Details")] string? details = null,
|
||||
[FromForm(Name = "HeightReporter")] float? heightReporter = null,
|
||||
[FromForm(Name = "HeightReported")] float? heightReported = null,
|
||||
[FromForm(Name = "RoomId")] long roomId = 0)
|
||||
{
|
||||
if (playerIdReported is not long target || target <= 0)
|
||||
return Ok(new { success = false, error = "missing_target" });
|
||||
|
||||
var reporter = this.RequireCurrentPlayerId();
|
||||
if (reporter == target)
|
||||
return Ok(new { success = false, error = "cannot_report_self" });
|
||||
|
||||
var trimmed = (details ?? string.Empty).Trim();
|
||||
if (trimmed.Length > 1000) trimmed = trimmed[..1000];
|
||||
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = reporter,
|
||||
TargetPlayerId = target,
|
||||
Category = reportCategory,
|
||||
Message = trimmed,
|
||||
RoomId = roomId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"[mod] report filed: reporter={Reporter} target={Target} category={Cat} room={Room}",
|
||||
reporter, target, reportCategory, roomId);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── /v3/voteToKick — room-vote kick ──────────────────────────────
|
||||
[HttpPost("api/PlayerReporting/v3/voteToKick")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public IActionResult VoteToKick(
|
||||
[FromForm(Name = "PlayerId")] long? playerId,
|
||||
[FromForm(Name = "Response")] bool? response,
|
||||
[FromForm(Name = "GameSessionId")] long gameSessionId = 0)
|
||||
{
|
||||
var caller = this.RequireCurrentPlayerId();
|
||||
logger.LogInformation(
|
||||
"[mod] vote-to-kick by {Caller}: target={Target} yes={Yes} session={Session}",
|
||||
caller, playerId, response, gameSessionId);
|
||||
// Real implementation would tally votes per (session, target) and
|
||||
// push a kick once the threshold tripped. For now we accept the
|
||||
// vote without side-effects so the watch UI's "voted" indicator
|
||||
// shows.
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── /v1/instantKick — admin one-shot ─────────────────────────────
|
||||
/// <summary>
|
||||
/// Admin/dev one-shot kick. The watch posts either
|
||||
/// <c>?playerId=N</c> for a single target (PlayerReporting.txt:3417)
|
||||
/// or a list body for bulk kicks (3590). Either form lands here.
|
||||
/// Gated to <see cref="PlayerEntity.IsAdmin"/> /
|
||||
/// <see cref="PlayerEntity.IsDeveloper"/>.
|
||||
/// </summary>
|
||||
[HttpPost("api/PlayerReporting/v1/instantKick")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> InstantKick(
|
||||
[FromQuery(Name = "playerId")] long? singleId,
|
||||
[FromForm(Name = "PlayerId")] long? formId,
|
||||
[FromForm(Name = "PlayerIds")] string? formIds)
|
||||
{
|
||||
var caller = this.RequireCurrentPlayerId();
|
||||
if (!await IsCallerAdminOrDevAsync(caller))
|
||||
return Forbid();
|
||||
|
||||
var ids = new List<long>();
|
||||
if (singleId is long s && s > 0) ids.Add(s);
|
||||
if (formId is long f && f > 0) ids.Add(f);
|
||||
if (!string.IsNullOrEmpty(formIds))
|
||||
{
|
||||
foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
ids = ids.Distinct().ToList();
|
||||
if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" });
|
||||
|
||||
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.");
|
||||
}
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── /v1/deviceId — log mismatched device id ──────────────────────
|
||||
[HttpPost("api/PlayerReporting/v1/deviceId")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public IActionResult MismatchedDeviceWarning(
|
||||
[FromForm(Name = "oldDeviceId")] string? oldDeviceId)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[mod] mismatched device id reported: caller={Caller} old={Old}",
|
||||
this.CurrentPlayerId(), oldDeviceId);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── /api/PlayersBanned/v2/ban — admin ban ────────────────────────
|
||||
[HttpPost("api/PlayersBanned/v2/ban")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> Ban(
|
||||
[FromForm(Name = "PlayerId")] long? playerId,
|
||||
[FromForm(Name = "Reason")] int reason = 0,
|
||||
[FromForm(Name = "BanType")] int banType = 0,
|
||||
[FromForm(Name = "DisplayReason")] string? displayReason = null,
|
||||
[FromForm(Name = "BannedUntil")] string? bannedUntil = null)
|
||||
{
|
||||
var caller = this.RequireCurrentPlayerId();
|
||||
if (!await IsCallerAdminOrDevAsync(caller))
|
||||
return Forbid();
|
||||
|
||||
if (playerId is not long target || target <= 0)
|
||||
return Ok(new { success = false, error = "missing_target" });
|
||||
|
||||
// BannedUntil arrives as "MM/dd/yyyy HH:mm" per Moderation.txt:696.
|
||||
DateTime? until = null;
|
||||
if (!string.IsNullOrEmpty(bannedUntil) &&
|
||||
DateTime.TryParseExact(bannedUntil, "MM/dd/yyyy HH:mm",
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
until = parsed;
|
||||
}
|
||||
|
||||
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == target);
|
||||
if (p is null) return Ok(new { success = false, error = "no_such_player" });
|
||||
p.BannedUntil = until ?? DateTime.UtcNow.AddYears(99);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
|
||||
caller, target, reason, p.BannedUntil, displayReason);
|
||||
|
||||
// Push a ModerationRoomBan notification so the target watch
|
||||
// shows the ban dialog and disconnects (PlayerReporting.txt:2253).
|
||||
await notifications.NotifyAsync(
|
||||
target,
|
||||
Models.Notification.PushNotificationId.ModerationRoomBan);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
private async Task<bool> IsCallerAdminOrDevAsync(long callerId) =>
|
||||
await db.Players
|
||||
.Where(p => p.Id == callerId)
|
||||
.Select(p => p.IsAdmin || p.IsDeveloper)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
public class NotificationController(DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
|
||||
{
|
||||
error = "use_signalr_hub",
|
||||
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
|
||||
});
|
||||
|
||||
/// <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>
|
||||
[HttpGet("/api/notification/hub/v1")]
|
||||
public IActionResult HubInfo() => Ok(new
|
||||
{
|
||||
Url = $"https://{domain.Sub("notify")}/hub/v1",
|
||||
Protocol = "signalr",
|
||||
Version = 1,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.NotificationPrefs;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/messages/v1/IOSGetNotificationPreferences +
|
||||
/// IOSModifyNotificationPreferences. Wire DTO matches
|
||||
/// <c>RecNet.IOSNotificationPreferences</c> (decompiled at
|
||||
/// <c>Cpp2IL_CS/.../RecNet/IOSNotificationPreferences.cs</c>):
|
||||
/// <c>ChatMessage(bool), FriendInvite(bool), FavoriteFriendOnline(bool)</c>.
|
||||
///
|
||||
/// Persists via <see cref="NotificationPrefsEntity"/> keyed by
|
||||
/// (PlayerId, Platform). Defaults to all-on when no row exists yet.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class IOSNotificationPrefsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("api/messages/v1/IOSGetNotificationPreferences")]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var pid = Me;
|
||||
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid);
|
||||
return Ok(ToWire(row));
|
||||
}
|
||||
|
||||
public sealed class IOSPrefsRequest
|
||||
{
|
||||
public bool ChatMessage { get; set; } = true;
|
||||
public bool FriendInvite { get; set; } = true;
|
||||
public bool FavoriteFriendOnline { get; set; } = true;
|
||||
}
|
||||
|
||||
[HttpPost("api/messages/v1/IOSModifyNotificationPreferences")]
|
||||
public async Task<IActionResult> Modify([FromBody] IOSPrefsRequest body)
|
||||
{
|
||||
var pid = Me;
|
||||
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid);
|
||||
if (row is null)
|
||||
{
|
||||
row = new NotificationPrefsEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Platform = "ios",
|
||||
};
|
||||
db.NotificationPrefs.Add(row);
|
||||
}
|
||||
row.AllowMessage = body.ChatMessage;
|
||||
row.AllowFriendRequest = body.FriendInvite;
|
||||
row.AllowAnnouncements = body.FavoriteFriendOnline;
|
||||
row.AllowAll = body.ChatMessage || body.FriendInvite || body.FavoriteFriendOnline;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(row));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>IOSResetNotificationPreferencesBadgeCount</c> —
|
||||
/// the watch sends this when the user opens the iOS app to clear
|
||||
/// the badge counter. No persistent state to mutate; ack.</summary>
|
||||
[HttpPost("api/messages/v1/IOSResetNotificationPreferencesBadgeCount")]
|
||||
public IActionResult ResetBadge() => Ok(new { Reset = true });
|
||||
|
||||
public sealed class SaveTokenRequest
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
public string? DeviceToken { get; set; }
|
||||
public string? Platform { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v1/IOSSaveDeviceToken</c> —
|
||||
/// upserts the caller's APNS token to
|
||||
/// <see cref="PushTokenEntity"/> for a future APNS proxy to fan
|
||||
/// out push notifications.</summary>
|
||||
[HttpPost("api/messages/v1/IOSSaveDeviceToken")]
|
||||
public async Task<IActionResult> SaveDeviceToken(
|
||||
[FromBody] SaveTokenRequest? body,
|
||||
[FromForm(Name = "Token")] string? tokenForm,
|
||||
[FromForm(Name = "DeviceToken")] string? deviceTokenForm)
|
||||
{
|
||||
var pid = Me;
|
||||
var token = body?.Token ?? body?.DeviceToken ?? tokenForm ?? deviceTokenForm ?? string.Empty;
|
||||
var platform = body?.Platform ?? "ios";
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return BadRequest(new { error = "missing token" });
|
||||
|
||||
var row = await db.PushTokens.FirstOrDefaultAsync(t =>
|
||||
t.PlayerId == pid && t.Platform == platform);
|
||||
if (row is null)
|
||||
{
|
||||
row = new PushTokenEntity { PlayerId = pid, Platform = platform };
|
||||
db.PushTokens.Add(row);
|
||||
}
|
||||
row.Token = token;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Saved = true });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v1/IOSClearDeviceToken</c> —
|
||||
/// drops every push-token row for the caller (e.g. on iOS
|
||||
/// logout).</summary>
|
||||
[HttpPost("api/messages/v1/IOSClearDeviceToken")]
|
||||
public async Task<IActionResult> ClearDeviceToken()
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await db.PushTokens.Where(t => t.PlayerId == pid).ToListAsync();
|
||||
if (rows.Count > 0)
|
||||
{
|
||||
db.PushTokens.RemoveRange(rows);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(new { Cleared = rows.Count });
|
||||
}
|
||||
|
||||
private static object ToWire(NotificationPrefsEntity? r) => new
|
||||
{
|
||||
ChatMessage = r?.AllowMessage ?? true,
|
||||
FriendInvite = r?.AllowFriendRequest ?? true,
|
||||
FavoriteFriendOnline = r?.AllowAnnouncements ?? true,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user