Compare commits

...

34 Commits

Author SHA1 Message Date
Alexa fd93b4b028 Merge remote-tracking branch 'origin/fix/postgres-bootstrap' into fix/2023-room-instances-publish-permissions
# Conflicts:
#	DorkNet.Server/Startup/DatabaseBootstrap.cs
2026-10-25 14:03:23 +02:00
Alexa 8be0f90dbc fix: sub-room id sequence, restore sync, presence index TTL, permission races
From the local CodeRabbit review:
- Scene ids: the repair and room clone assigned max + 1, which never
  advanced the Postgres identity sequence (live: sequence 3412, max id 3466),
  so the next add / clone sub-room would collide on the primary key. The
  repair now moves the sequence past the highest id at startup, and both
  let the database assign ids.
- Room-level restores (datahistory/restore and the admin restore) now also
  restore the entry sub-room, whose blob room details serve first.
- TouchRoom refreshes the room's presence index too, so players kept alive
  only by heartbeats no longer drop out of the instance browser after 20 min.
- Sub-room permission saves match cells exactly (as the unique index does)
  and retry when a concurrent editor inserted the same new cell.
2026-10-25 13:59:30 +02:00
Alexa d0d26c9821 fix: wait for the bootstrap lock without a command timeout; safe unlock
From the local CodeRabbit review:
- A replica waiting for the lock used the default 30 s command timeout and
  failed to boot while another replica seeded a fresh database. Wait with
  no timeout, logging that it is waiting.
- An unlock failure in the finally (e.g. the connection already broken)
  replaced the real bootstrap error and skipped closing the connection.
  Log it instead, and always close.
2026-10-25 13:52:20 +02:00
Alexa bb22675a91 fix: hold the Postgres bootstrap lock across schema creation and seeding
Booting several replicas against one fresh Postgres database left all but
one dead on startup: the advisory lock only covered EnsureCreated, so the
seeders (system account, rooms, playlists, store, ...) raced and hit
duplicate keys. Take a session-level advisory lock for the whole bootstrap
and release it explicitly (pooled connections would otherwise keep it).
The upgrades and seeds both providers run move into
RunUpgradesAndSeedsAsync.
2026-10-25 13:01:43 +02:00
Alexa c1000c82aa fix: let EF own the Postgres bootstrap transaction (EF Core 10)
EF Core 10 runs EnsureCreated through the migration executor, which begins
its own transaction unless EF already has one. The bootstrap held the
advisory lock in a transaction begun on the raw connection, invisible to
EF, so a fresh Postgres database failed at startup with "A transaction is
already in progress". Begin it through db.Database and take the lock with
ExecuteSqlRaw so EnsureCreated runs inside it.
2026-10-25 13:00:16 +02:00
Alexa 9076fb9fae fix: let EF own the Postgres bootstrap transaction (EF Core 10)
EF Core 10 runs EnsureCreated through the migration executor, which begins
its own transaction unless EF already has one. The bootstrap held the
advisory lock in a transaction begun on the raw connection, invisible to
EF, so a fresh Postgres database failed at startup with "A transaction is
already in progress". Begin it through db.Database and take the lock with
ExecuteSqlRaw so EnsureCreated runs inside it.
2026-10-25 12:56:13 +02:00
Alexa cf7a021898 fix: repair a missing entry sub-room under the bootstrap lock
From the local CodeRabbit review:
- Repair rooms that lack an OrderIndex 0 (entry) sub-room even when they
  have other sub-rooms, not only rooms with none.
- Every service runs the database bootstrap at startup, so on Postgres
  replicas booting together could hand out the same scene ids (max + 1)
  and one would fail startup on the primary key. Run the repair in a
  transaction holding the bootstrap's advisory lock.
2026-10-25 12:52:53 +02:00
Alexa 78c64d117a Merge remote-tracking branch 'origin/march-2023-03-21' into fix/2023-room-instances-publish-permissions 2026-10-25 11:59:05 +02:00
Alexa e7c0b076f2 Merge pull request #68 from DorkSquadRR/ci/jenkins-jdk25 2026-10-25 11:58:51 +02:00
Alexa 0a3bad7c6c Merge pull request #67 from DorkSquadRR/feat/quest-install-latest-build 2026-10-25 11:57:05 +02:00
Alexa 1718912a07 Merge pull request #66 from DorkSquadRR/ci/downloads-page 2026-10-25 11:56:51 +02:00
Alexa 5acad4c9e2 Merge pull request #65 from DorkSquadRR/ci/jenkins-jdk25 2026-10-25 11:56:38 +02:00
Alexa ec88a39750 ci: leave the downloads page to the DorkNet site
The server now offers the published builds on its own site (Downloads page,
Latest build on the Quest page), reading the bucket on the internal network.
Drop the standalone index.html and DORKNET_SITE_URL; the bucket needs no
public route or access app of its own.
2026-10-25 11:53:23 +02:00
Alexa 6e5b011059 ci: publish client builds to a downloads page under player-facing names
Each run now stages what it built as DorkNet-<release>-PC-Full.zip,
-PC-Patch.zip, -Quest.xapk and -Quest-Installer.zip, writes their sizes and
SHA-256 to a manifest, and uploads them with a static page to an S3 bucket
served read-only (ci/downloads). The build scripts keep their own names,
which the launcher depends on; only the published copies are renamed.

PUBLISH_DOWNLOADS (on by default) turns the upload off per run. Jenkins
archives the same renamed files instead of the raw build output.
2026-10-25 11:53:23 +02:00
Alexa 2e09042344 ci: run the agent with an init process; document the controller upgrade
- init: true on the agent so processes left behind by build tools are reaped,
  as the inbound-agent image asks for.
- Redeploying an older controller from docker-compose.yml upgrades Jenkins
  and moves it from Java 17 to 25: say to back up jenkins_home first, update
  plugins that fail to load, and run the job once before relying on it.
2026-10-25 11:53:21 +02:00
Alexa 9c5b574d6e ci: keep docker-compose.yml as the controller; move the agent to its own file
Turning ci/jenkins/docker-compose.yml from the controller into the agent meant
that redeploying a Jenkins set up from the old file would replace the
controller with an agent. Keep that file as the controller instead: the same
jenkins service and jenkins_home volume, now on the stock Java 25 image (its
old build context, ci/jenkins/Dockerfile, is the agent image now). The agent
moves to docker-compose.agent.yml. This replaces the previous commit's warning.
2026-10-25 11:50:09 +02:00
Alexa 29aca0e88c docs(ci): warn against redeploying an old controller app from the agent compose
ci/jenkins/docker-compose.yml used to define the Jenkins controller and now
defines only the agent. Redeploying an application that was set up from the
old file would replace the controller with an agent. Say so in the file, and
add an upgrade path to the README: move the controller to its own service on
the same data volume first, then deploy the agent as a new application.
2026-10-25 11:47:49 +02:00
Alexa eb73f3c484 fix: serve downloads only on the site host and select the clicked Quest build
Review follow-ups:
- The downloads routes now answer only on the apex (the site's host) and 404
  on every other accepted host, so the game's open API/CDN subdomains can't
  serve the builds.
- Install on Quest passes its own file to the Quest page instead of always
  picking the newest release.
- Waiting for the bucket's headers is limited to 30 s; the body still streams
  without a limit.
- Dispose the upstream request message; use LINQ in FindFile.
2026-10-25 11:06:43 +02:00
Alexa cc7f77b7de feat(site): Downloads page and Quest install from the latest build
When the server is pointed at the bucket CI publishes builds to
(DorkNet:Downloads:BaseUrl), the site lists them on a new Downloads page and
the Quest page offers "Latest build" next to "Choose file".

The server reads manifest.json and the files over HTTP on the internal network
and streams them through with Range support (DownloadsController); only files
the manifest lists are served. The xapk reader now reads from a byte source,
either the picked File or the server URL via Range requests, so the browser
installer streams the APK and OBB to the headset without downloading the
package first. Unset, both features stay hidden.
2026-10-25 10:55:43 +02:00
Alexa 19402459a9 ci: deploy the agent with base directory ci/jenkins
The base directory is the compose build context; from the repo root the
build used the server's Dockerfile instead of ci/jenkins/Dockerfile.
2026-10-25 01:21:07 +03:00
Alexa 3266ce47f3 ci: keep the agent's Jenkins URL and node secret runtime-only
Coolify interpolates the compose file during the image build with only
build-time variables. Marking JENKINS_URL and JENKINS_SECRET required
(${VAR:?}) made the build fail when they are runtime-only, and making them
build-time would pass the node secret as a build argument.
2026-10-25 01:18:35 +03:00
Alexa cf3e6ea025 ci: run client builds on a Java 25 build agent instead of the controller
The toolchain image becomes a Jenkins inbound agent (jenkins/inbound-agent,
Java 25) labelled dorknet-builder. Jenkins itself stays on the stock image
and runs no builds, which also clears the built-in-node warning.

- Dockerfile: inbound-agent base; restic cache on the agent's own volume
- docker-compose.yml: the agent as a Coolify Docker Compose application,
  reaching Jenkins over WebSocket on the predefined Coolify network
- Jenkinsfile: agent { label 'dorknet-builder' }
- README: Java 25 for both sides (Java 21 support ends Sep 2027, Java 17
  is unsupported), restart policy, node setup and Coolify deployment
2026-10-25 01:14:17 +03:00
Alexa bf0f09ce84 fix: answer 2023 room and playlist mutations with the success/value/error envelope
Every rooms-client mutation (roles, invite, accessibility/publish, room
fields, sub-room ops, permissions, playlists) goes through
NLDBPDCNNCF.BDBHCEGKGDC<TResult>, the same helper and instantiation as
"clone room", which reads {success, value, error}. A bare room-details body
has no success, so the client rejected it with the message-less
"Exception of type 'CEMNLBKJABA' was thrown" (seen on co-owner invite)
although the server had applied the change.

RecNetEnvelopeAttribute wraps those handlers: 2xx becomes
{success:true, value, error:""}, refusals become 200
{success:false, value:null, error:<message>}.
2026-10-24 23:48:00 +03:00
Alexa fd2153d82b fix: 2023 room instances, publish, role permissions and co-owner invites
- Room roles: list the creator as Creator (255), not CoOwner (30). The
  client takes the local role from the Roles list and only lets a player
  edit roles below their own (Creator excepted), so the creator could not
  edit Co-Owner and Creator permissions. Enforce the role ladder on the
  server (RoomRoleRules), let invitees accept (PUT roles/{me} role=30) and
  reject (PUT roles/{me}/invite role=0), send message Types 62/60/61, and
  push RoomUpdate after every role change.
- Permissions: persist PUT subrooms/{sub}/permissions cells in a new
  RoomRolePermissions table, serve them from photon_access_token (plus a
  roomserver/ twin) and push PhotonAccessToken to the sub-room.
- Publish: player-owned rooms are never IsRRO on the wire; the client
  hides Publish and every room setting for an RRO.
- Sub-rooms: wire SubRoomId = OrderIndex + 1 (the client will not load a
  sub-room with id 0), new rooms always get a named Home sub-room, and a
  startup pass repairs rooms without one.
- Instance browser: per-room Redis presence index, live players only,
  private instances for the people allowed in, real IsFull.
- Room/sub-room settings: creator or accepted co-owner everywhere.
2026-10-24 23:22:53 +03:00
Alexa 0839d932ce Merge pull request #64 from DorkSquadRR/fix/login-sign-in-instead-of-auto-create
fix: open the sign-in page instead of creating a new account on a fresh client
2026-10-24 23:21:12 +03:00
Alexa 9523525cb8 fix: register the title-screen login patch so it actually applies on PC
GetPatchMethod only resolves patch methods from a fixed list of holder
classes; TitleScreenLoginPatches was missing, so the prefix failed with
[patch-fail] and a fresh PC client still looped on account creation.
Also log the real exception behind a failed sign-in redirect instead of
the TargetInvocationException wrapper.
2026-10-24 22:50:54 +03:00
Alexa 3880745efd fix: open the sign-in page instead of creating a new account on a fresh client
With no remembered account, the 2023 title screen creates a brand-new
account (grant_type create_account). On a server with sign-ups off that
fails and the title screen retries forever. PC (ClientMod) and Quest
now open the sign-in page instead, so players log into their existing
DorkNet account. AutoCreateAccount = true keeps Rec Room's behaviour.

Also replaces a literal NUL byte in the Quest config loader with '\0'.
2026-10-24 22:45:57 +03:00
Alexa 31d5a022c8 Merge pull request #62 from DorkSquadRR/ci/jenkins-client-builds
Build the PC and Quest clients on Jenkins
2026-10-24 20:37:05 +03:00
Alexa 83b896ffaf fix: drop an unused import and make the build scripts' exits explicit 2026-10-24 20:30:40 +03:00
Alexa fcc734726a Merge pull request #61 from DorkSquadRR/fix/notify-2023-contract
Notification pushes now match what the 2023 client reads
2026-10-24 20:28:51 +03:00
Alexa b1c1239165 ci: build the PC and Quest clients on Jenkins
A Jenkinsfile builds, on push (only what changed) or on demand:
- the PC patcher zip (MelonLoader, the mod, config template, Steam stub),
- the full PC client (stock build + patcher + config),
- the Quest APK and .xapk.

Stock builds come out of the restic build archive on the FTP: restic
through its rclone backend, picked by snapshot tag per run and cached.
The FTP login and restic password are Jenkins credentials, and the
repository folders are environment settings, so nothing about the
archive is fixed in the repo.

ci/jenkins/Dockerfile is the Jenkins image with the toolchain added
(.NET SDK, Android NDK and build-tools, apktool, Python with LIEF,
restic, rclone), for the existing Jenkins service.

The build scripts move from PowerShell to Python so they run the same
on Windows, Linux and in CI:
- DorkNet.Quest/scripts/build.py replaces build.ps1 + package.ps1; it
  can use a committed hook table (checked against the APK's
  libil2cpp.so) instead of dump.cs, and takes a persistent keystore.
- tools/build_pc.py replaces build-patcher-zip.ps1 (now with the
  2023 install layout) and adds the full-client build.

Setup steps are in ci/jenkins/README.md.
2026-10-24 20:26:42 +03:00
Alexa afad9bbc5a fix: address review on the notification pushes
- Force-join's RoomUpdate carries the room's role grants, so it no
  longer wipes them from the client's cached room.
- The consumable push reports IsTransferable the same as the inventory
  list does.
- A rejected private-instance join sends the owner at most one "wants
  to join" message per requester and instance every 5 minutes.
- SubscribeToPlayers ignores sets over 5,000 ids and keeps the current
  subscriptions.
- The subscriber exclusion list looks up online friends' connections in
  one pipelined Redis batch instead of one call per friend.
2026-10-24 20:25:51 +03:00
Alexa e33145307f fix: notification pushes now match what the 2023 client reads
Most pushes were still shaped for the 2020 watch, so the 2023 client
parsed them as empty or dropped them without any error.

- RelationshipChanged carries the relationship row, to both players, on
  friend request / accept / remove / add and on favorite / mute / ignore.
  Friend requests and accepts also land in the notifications inbox.
- ModerationKick uses the client's flat moderation block, with the
  reason in VoteKickReason (the only field the dialog shows).
- Bans, account deletion, password resets and second-session takeover
  are a Logout with a numeric reason instead of a kick to the dorm; the
  old string reason failed the client's parse.
- SubscribeToPlayers is honoured: each connection joins a presence group
  per subscribed player, and presence (only presence) reaches subscribers
  as well as friends. The hub allows 180 s of client silence, since the
  client reconnects at most 3 times per login.
- Admin broadcast sends its text as a coach message and can start the
  client's own maintenance countdown; ServerMaintenance only reads
  StartsInMinutes.
- ConsumableMapping, StorefrontBalance, gift, player event, level-up,
  RoomUpdate, profile-image and PlayerLeftChat pushes carry the client's
  DTOs. "Join rejected" is the client's RequestGameInvite message.

Contract in docs/recroom-2023-notifications.md; push frames are checked
by NotificationContractTests through a capturing hub context.
2026-10-24 19:50:05 +03:00
Alexa 38335c06a9 Merge pull request #60 from DorkSquadRR/fix/room-clone-envelope
Creating a room no longer fails with a blank "Failed to copy room"
2026-10-17 20:21:43 +03:00
84 changed files with 10323 additions and 1432 deletions
+2 -2
View File
@@ -43,10 +43,10 @@ jobs:
Compress-Archive -Path publish/server/* `
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
# ── Client patcher zip (built via tools/build_pc.py) ─────────────
- name: Build client patcher zip
run: |
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
# ── Publish both as Release assets ───────────────────────────────
- name: Upload Release assets
+64 -1
View File
@@ -156,6 +156,14 @@ public class Mod : MelonMod
// "N"). Empty = no hotkey. A hotkey also arms the feature when
// RecRoyaleNightMode is false, so it can be switched on in-game.
public static string RecRoyaleNightToggleKey = "";
// A client with no remembered account for this machine. false: the
// title screen opens the sign-in page so the player logs into their
// existing account. true: Rec Room's own behaviour, which creates a
// new account automatically -- only useful when the server allows
// sign-ups (otherwise the title screen keeps retrying and failing).
// See TitleScreenLoginPatches.
public static bool AutoCreateAccount = false;
}
public override void OnInitializeMelon()
{
@@ -225,6 +233,14 @@ public class Mod : MelonMod
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
}
// Title-screen auto-login: with no remembered account, open the
// sign-in page instead of creating a new account.
if (!Cfg.AutoCreateAccount)
{
TryPatchByName("RRUI.Data.TitleScreenFlowModel", "IAKHIJIHAPJ",
prefix: nameof(TitleScreenLoginPatches.AutoLogin_Prefix));
}
// Rec Royale night mode: armed when enabled OR when a hotkey is set
// (so it can be switched on in-game). Scene detection + mood apply
// run from OnSceneWasLoaded / OnUpdate below.
@@ -457,6 +473,7 @@ public class Mod : MelonMod
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
if (TryGetConfigValue(r, "AutoCreateAccount", out v)) Cfg.AutoCreateAccount = v.GetBoolean();
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
{
@@ -662,7 +679,7 @@ public class Mod : MelonMod
private MethodInfo GetPatchMethod(string name)
{
// Look in the core + feature patch holder classes.
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) })
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight), typeof(TitleScreenLoginPatches) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
@@ -903,6 +920,52 @@ internal static class FileHashCheckerPatches
return false;
}
}
internal static class TitleScreenLoginPatches
{
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time
// auto-login: the title screen downloads this machine's remembered
// accounts (CFILOMKCAPL), picks one (BFHOLLPGBPB) and logs into it here.
// With none (null) it creates a brand-new account instead (PJMGFADOEEM ->
// grant_type create_account). A server with sign-ups off refuses that, the
// error handler (IBDFENJNMAA) shows the connection-error page and the flow
// tries again, forever. Open the sign-in page instead (GoToLogin, what the
// "Log in" button does). ManuallyLogin carries the player into the game
// from there on its own, so the boot step gets a promise that never
// settles (MAMJDBJDFAF, the game's non-generic promise).
private static Type? _promiseType;
public static bool AutoLogin_Prefix(object __instance, object? __0, MethodBase __originalMethod, ref object? __result)
{
if (__0 is not null || Mod.Cfg.AutoCreateAccount) return true;
try
{
_promiseType ??= Mod.ResolveType("MAMJDBJDFAF");
var goToLogin = __instance.GetType().GetMethod("GoToLogin", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null);
if (_promiseType is null || goToLogin is null)
{
Mod.Log.Warning("[title] no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
return true;
}
// Il2CppInterop wraps the interface return type (LDGADANDBIO) as
// its own class, so hand back the new promise's native object
// wrapped in that type.
var pending = (Il2CppObjectBase)Activator.CreateInstance(_promiseType)!;
var returnType = ((MethodInfo)__originalMethod).ReturnType;
var result = Activator.CreateInstance(returnType, pending.Pointer);
goToLogin.Invoke(__instance, null);
__result = result;
Mod.Log.Msg("[title] no remembered account; opened the sign-in page instead of creating a new account");
return false;
}
catch (Exception ex)
{
// goToLogin.Invoke wraps the real failure in TargetInvocationException.
var cause = ex is TargetInvocationException { InnerException: not null } tie ? tie.InnerException! : ex;
Mod.Log.Warning($"[title] sign-in redirect failed, creating an account as Rec Room does: {cause.GetType().Name}: {cause.Message}");
return true;
}
}
}
internal static class ToxModPatches
{
public static bool CanInitializeToxMod_Prefix(ref bool __result)
@@ -1,6 +1,7 @@
{
"ServerHost": "{HOST}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
"AutoCreateAccount": false,
"EnableDebugConsole": false,
"DebugConsoleToggleKey": "BackQuote",
"DevCommands": [],
+28 -17
View File
@@ -30,7 +30,8 @@ Photon shards with PC DorkNet players with no server-side version change.
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — |
| Oculus `ovr_*` platform calls | (no-Meta build only) pass the entitlement / user-proof / logged-in-user requests Meta refuses for the renamed app (including the SDK's second check on the inner native message), answering the logged-in user with the placeholder user id `1` | — |
| title-screen auto-login (`TitleScreenFlowModel`) | a headset with no remembered account opens the **sign-in page** instead of auto-creating an account (`AutoCreateAccount = true` restores Rec Room's behaviour) | — |
### Two builds, selected by `OculusAppId`
@@ -60,7 +61,7 @@ Photon shards with PC DorkNet players with no server-side version change.
and its matching **OBB**.
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
> can't write under `Documents`. `build.ps1` builds in `%TEMP%` by default;
> can't write under `Documents`. `build.py` builds in `%TEMP%` by default;
> keep the work dir outside protected folders.
## Build
@@ -76,20 +77,28 @@ dotnet Il2CppDumper.dll `
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
# 3. Build the signed APK:
# 3. Build the signed APK (and, with --obb, the release package):
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
DorkNet.Quest\scripts\build.ps1 `
-Apk C:\path\to\RecRoom-store-2128210.apk `
-DumpCs <scratch>\dump\dump.cs `
-Obb C:\path o\main.2128210.com.AgainstGravity.RecRoom.obb
py -3 DorkNet.Quest\scripts\build.py build `
--apk C:\path\to\RecRoom-store-2128210.apk `
--dump-cs <scratch>\dump\dump.cs `
--obb C:\path\to\main.2128210.com.AgainstGravity.RecRoom.obb `
--keystore C:\path\to\dorknet-debug.keystore
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with -Obb)
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with --obb)
```
With `-Obb`, the build also runs `scripts/package.ps1`, which writes a release
folder ready to hand to players:
The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`).
For a build whose target table is already committed under `targets/`, pass
`--targets-json DorkNet.Quest/targets/20230317-2128210.json` instead of
`--dump-cs`. No Il2CppDumper is needed then: the build checks the table
against the APK's `libil2cpp.so` and stops on a mismatch. This is what the
Jenkins job does (`ci/jenkins/README.md`).
With `--obb`, the build also writes a release folder ready to hand to players:
| File | Purpose |
| --- | --- |
@@ -98,15 +107,16 @@ folder ready to hand to players:
| `install.sh` | one-command install on macOS / Linux |
| `README.txt` | player instructions |
To package an APK you already built, run `scripts/package.ps1 -Apk <apk> -Obb <obb>`.
To package an APK you already built, run `build.py package --apk <apk> --obb <obb>`.
The package contains game binaries from your store copy, so only share it with
people who own the game.
`build.ps1` chains: resolve targets → generate headers → compile
`build.py build` chains: resolve targets → generate headers → compile
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
zipalign → apksigner (v2+v3). It generates a fresh debug keystore in the work
dir on first run. **Keep that keystore** — Meta ties tokens to app id + package
zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug
keystore in the work dir on first run. **Keep that keystore** and pass it with
`--keystore` from then on — Meta ties tokens to app id + package
+ signing cert, so re-signing with a different key breaks any Meta registration.
It is never committed.
@@ -189,12 +199,14 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
| Symptom | Likely cause |
| --- | --- |
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.ps1`, don't hand-zip |
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.py`, don't hand-zip |
| boots to an empty world | OBB missing or named for the wrong versionCode |
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
| multiplayer never connects | Photon app ids not set in the cfg |
| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` |
| "Unable to load Oculus user account" | old build (before the GetLoggedInUser bypass), or a Meta build whose app id the account isn't entitled to |
| game restarts while "creating a new account" | `AutoCreateAccount = true` on a server with sign-ups off — set it to `false` so the sign-in page opens |
## Layout
@@ -209,8 +221,7 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
| `scripts/build.ps1` | one-shot: store APK → signed patched APK (+ release package with `-Obb`) |
| `scripts/package.ps1` | patched APK + OBB → `.xapk` + installers |
| `scripts/build.py` | `build`: store APK → signed patched APK (+ release package with `--obb`); `package`: patched APK + OBB → `.xapk` + installers |
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
+6
View File
@@ -65,6 +65,12 @@ EnableTlsTrustBypass = true
# Accept image Content-Signatures the server signs with its own key.
AcceptUnsignedImages = true
# A headset with no remembered account. false: open the sign-in page so the
# player logs into their existing DorkNet account. true: Rec Room's own
# behaviour, which creates a new account automatically -- only useful when the
# server allows sign-ups (otherwise the game keeps retrying and failing).
AutoCreateAccount = false
# ---- diagnostics (adb logcat -s DORKNET) ----
LogHttp = true
-248
View File
@@ -1,248 +0,0 @@
<#
.SYNOPSIS
Build a DorkNet-patched Rec Room Quest APK from a pristine store APK.
.DESCRIPTION
End to end:
1. resolve_targets.py -> per-build target table (RVA + fingerprint)
2. gen_targets_header.py -> targets_generated.h
3. gen_config_header.py -> config_generated.h (from your cfg)
4. NDK clang++ -> libdorknet.so (arm64)
5. apktool decode -> working project
6. package rename + manifest/version edits
7. drop libdorknet.so in, DT_NEEDED it into libmain.so (LIEF)
8. apktool build -> zipalign -> apksigner -> the signed output APK
Nothing game-derived is written into the repo. The work directory defaults to
a temp path because Windows Controlled Folder Access blocks the NDK linker and
apksigner from writing under Documents; override with -WorkDir if needed.
You supply the store APK and (for install) the matching OBB. No game binaries
ship with this tool.
.PARAMETER Apk
Path to the pristine store APK (com.AgainstGravity.RecRoom, versionCode
2128210 / version 20230317).
.PARAMETER Config
dorknet-quest.cfg with your ServerHost / Photon ids / package name. Defaults
to dorknet-quest.cfg next to the repo folder; falls back to the .example.
.PARAMETER WorkDir
Scratch build directory (must be outside Controlled-Folder-Access-protected
locations). Defaults to $env:TEMP\dorknet-quest-build.
.PARAMETER OutApk
Where to write the finished APK. Default: <WorkDir>\dorknet-recroom-<ver>.apk.
.PARAMETER NdkRoot
Android NDK root. Default: $env:ANDROID_NDK, else the NDK under $env:ANDROID_SDK_ROOT\ndk.
.PARAMETER BuildToolsDir
Android SDK build-tools dir holding zipalign + apksigner. Default: resolved
from $env:ANDROID_SDK_ROOT.
.PARAMETER Obb
The store OBB matching the APK. When given, package.ps1 also bundles the
result into an installable .xapk with one-command installers.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Apk,
[string]$Config,
[string]$WorkDir = (Join-Path $env:TEMP 'dorknet-quest-build'),
[string]$OutApk,
[string]$NdkRoot,
[string]$BuildToolsDir,
[string]$ApktoolJar,
[string]$DumpCs,
[string]$ApiLevel = '24',
[string]$Obb
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$RepoDir = Split-Path -Parent $PSScriptRoot
$Scripts = $PSScriptRoot
$OldPkg = 'com.AgainstGravity.RecRoom'
$SoName = 'libdorknet.so'
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
# ---- resolve tools ------------------------------------------------------
if (-not $NdkRoot) {
$NdkRoot = $env:ANDROID_NDK
if (-not $NdkRoot -and $env:ANDROID_SDK_ROOT) {
$cand = Join-Path $env:ANDROID_SDK_ROOT 'ndk'
if (Test-Path $cand) { $NdkRoot = (Get-ChildItem $cand -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName }
}
}
if (-not $NdkRoot) { throw "Android NDK not found. Pass -NdkRoot or set ANDROID_NDK / ANDROID_SDK_ROOT." }
$Cxx = Join-Path $NdkRoot "toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android$ApiLevel-clang++.cmd"
$ObjDump = Join-Path $NdkRoot 'toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-objdump.exe'
Need $Cxx 'NDK aarch64 clang++'
if (-not $BuildToolsDir) {
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
}
$ZipAlign = Join-Path $BuildToolsDir 'zipalign.exe'
$ApkSigner = Join-Path $BuildToolsDir 'apksigner.bat'
Need $ZipAlign 'zipalign'
Need $ApkSigner 'apksigner'
if (-not $ApktoolJar) {
$ApktoolJar = if ($env:APKTOOL_JAR) { $env:APKTOOL_JAR } elseif ($env:ANDROID_SDK_ROOT) { Join-Path $env:ANDROID_SDK_ROOT 'apktool.jar' } else { '' }
}
Need $ApktoolJar 'apktool.jar'
Need $Apk 'store APK'
if (-not $Config) {
$c = Join-Path $RepoDir 'dorknet-quest.cfg'
$Config = if (Test-Path $c) { $c } else { Join-Path $RepoDir 'dorknet-quest.cfg.example' }
}
Need $Config 'config'
Write-Host "Using config: $Config"
$PkgName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*PackageName\s*=' } | Select-Object -First 1)
$PkgName = if ($PkgName) { ($PkgName -split '=', 2)[1].Trim() } else { 'net.dorknet.RecRoom' }
$AppName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*AppName\s*=' } | Select-Object -First 1)
$AppName = if ($AppName) { ($AppName -split '=', 2)[1].Split('#')[0].Trim() } else { '' }
if (-not $AppName) { $AppName = 'DorkNet' }
# Resolve the launcher to its FULL exe path -- "& py ..." would otherwise match
# the (case-insensitive) function name and recurse. Prefer py -3.14, else python.
$PyCmd = (Get-Command py.exe -ErrorAction SilentlyContinue)
if ($PyCmd) { $PyExe = $PyCmd.Source; $PyPre = @('-3.14', '-I') }
else { $PyExe = (Get-Command python.exe -ErrorAction Stop).Source; $PyPre = @('-I') }
function Invoke-Py { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$a)
$pa = $PyPre + $a
& $PyExe @pa
if ($LASTEXITCODE) { throw "python failed: $($a -join ' ')" } }
# ---- work dirs ----------------------------------------------------------
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
$Gen = Join-Path $WorkDir 'gen'
$Project = Join-Path $WorkDir 'project'
$Extract = Join-Path $WorkDir 'extract'
New-Item -ItemType Directory -Force -Path $Gen, $Extract | Out-Null
# ---- 1. extract + dump + resolve targets --------------------------------
Step 'extracting libil2cpp.so + global-metadata.dat'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $Apk))
try {
foreach ($e in @('lib/arm64-v8a/libil2cpp.so', 'assets/bin/Data/Managed/Metadata/global-metadata.dat', 'lib/arm64-v8a/libmain.so')) {
$entry = $zip.GetEntry($e); if (-not $entry) { throw "APK missing $e" }
$dest = Join-Path $Extract (Split-Path $e -Leaf)
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
}
} finally { $zip.Dispose() }
$Il2Cpp = Join-Path $Extract 'libil2cpp.so'
$LibMain = Join-Path $Extract 'libmain.so'
if (-not $DumpCs) {
# Prefer a dump.cs the operator points at; otherwise require one. Dumping
# is left to Il2CppDumper (see README) to keep this script toolchain-light.
throw "Pass -DumpCs <dump.cs> from Il2CppDumper for this APK (see DorkNet.Quest/README.md)."
}
Need $DumpCs 'dump.cs'
Step 'resolving hook targets'
$verName = '20230317'; $verCode = '2128210'
# Write the resolved table into the work dir (Controlled Folder Access can
# block writes under Documents). The repo keeps a committed copy under targets/.
$table = Join-Path $WorkDir "$verName-$verCode.json"
Invoke-Py @((Join-Path $Scripts 'resolve_targets.py'), '--dump', $DumpCs, '--il2cpp', $Il2Cpp,
'--version-name', $verName, '--version-code', $verCode, '--objdump', $ObjDump, '--out', $table)
Step 'generating headers'
Invoke-Py @((Join-Path $Scripts 'gen_targets_header.py'), $table, (Join-Path $Gen 'targets_generated.h'))
Invoke-Py @((Join-Path $Scripts 'gen_config_header.py'), $Config, $PkgName, (Join-Path $Gen 'config_generated.h'))
# ---- 4. compile libdorknet.so -------------------------------------------
Step 'compiling libdorknet.so (arm64)'
$src = @(
(Join-Path $RepoDir 'src\dorknet_quest.cpp'),
(Join-Path $RepoDir 'src\config.cpp'),
(Join-Path $RepoDir 'src\third_party\And64InlineHook\And64InlineHook.cpp')
)
$So = Join-Path $WorkDir $SoName
& $Cxx '-std=c++17' '-fPIC' '-shared' '-O2' '-fvisibility=hidden' '-static-libstdc++' `
'-ffunction-sections' '-fdata-sections' '-Wl,--gc-sections' `
"-I$Gen" "-I$(Join-Path $RepoDir 'src')" $src '-llog' '-o' $So
if ($LASTEXITCODE) { throw 'compile failed' }
Need $So 'libdorknet.so'
# ---- 5. apktool decode --------------------------------------------------
Step 'apktool decode'
if (Test-Path $Project) { Remove-Item -Recurse -Force $Project }
& java -jar $ApktoolJar decode -f -o $Project (Resolve-Path $Apk)
if ($LASTEXITCODE) { throw 'apktool decode failed' }
# ---- 6. package rename + manifest -------------------------------------
Step "renaming package $OldPkg -> $PkgName"
$manifest = Join-Path $Project 'AndroidManifest.xml'
$m = Get-Content -Raw $manifest
$m = $m.Replace($OldPkg, $PkgName)
if ($m -notmatch 'android.intent.category.LAUNCHER') {
$m = $m.Replace('<category android:name="android.intent.category.INFO"/>',
'<category android:name="android.intent.category.INFO"/><category android:name="android.intent.category.LAUNCHER"/>')
}
Set-Content -NoNewline -Path $manifest -Value $m
# The launcher label comes from @string/app_name; set it in every locale.
Step "naming the app '$AppName'"
$labelXml = [System.Security.SecurityElement]::Escape($AppName).Replace("'", "\'")
foreach ($sx in Get-ChildItem (Join-Path $Project 'res') -Filter strings.xml -Recurse) {
$s = Get-Content -Raw -Encoding utf8 $sx.FullName
$n = [regex]::Replace($s, '<string name="app_name">[^<]*</string>', "<string name=`"app_name`">$labelXml</string>")
if ($n -ne $s) { Set-Content -NoNewline -Encoding utf8 -Path $sx.FullName -Value $n }
}
# ---- 7. inject the hook library ----------------------------------------
Step 'injecting libdorknet.so'
Copy-Item $So (Join-Path $Project 'lib\arm64-v8a' $SoName) -Force
$projMain = Join-Path $Project 'lib\arm64-v8a\libmain.so'
Invoke-Py @((Join-Path $Scripts 'inject_needed.py'), $projMain, $SoName)
# ---- 8. rebuild + align + sign -----------------------------------------
Step 'apktool build'
$unsigned = Join-Path $WorkDir 'unsigned.apk'
& java -jar $ApktoolJar build -f -o $unsigned $Project
if ($LASTEXITCODE) { throw 'apktool build failed' }
$Keystore = Join-Path $WorkDir 'dorknet-debug.keystore'
if (-not (Test-Path $Keystore)) {
Step 'generating a fresh debug keystore (kept in the work dir, never committed)'
& keytool -genkeypair -v -keystore $Keystore -alias dorknet -keyalg RSA -keysize 2048 `
-validity 10000 -storepass dorknet -keypass dorknet -dname 'CN=DorkNet Quest, O=DorkNet'
if ($LASTEXITCODE) { throw 'keytool failed' }
}
Step 'zipalign'
$aligned = Join-Path $WorkDir 'aligned.apk'
& $ZipAlign -f -p 4 $unsigned $aligned
if ($LASTEXITCODE) { throw 'zipalign failed' }
if (-not $OutApk) { $OutApk = Join-Path $WorkDir "dorknet-recroom-$verName.apk" }
Step "apksigner -> $OutApk"
& $ApkSigner sign --ks $Keystore --ks-key-alias dorknet --ks-pass pass:dorknet --key-pass pass:dorknet `
--v2-signing-enabled true --v3-signing-enabled true --out $OutApk $aligned
if ($LASTEXITCODE) { throw 'apksigner failed' }
& $ApkSigner verify --print-certs $OutApk | Out-Null
Write-Host ""
Write-Host "DONE. Patched APK: $OutApk" -ForegroundColor Green
Write-Host "Package: $PkgName versionCode: $verCode" -ForegroundColor Green
if ($Obb) {
Write-Host ""
& (Join-Path $Scripts 'package.ps1') -Apk $OutApk -Obb $Obb -BuildToolsDir $BuildToolsDir
} else {
Write-Host "Install it, then push the matching OBB to:" -ForegroundColor Green
Write-Host " /sdcard/Android/obb/$PkgName/main.$verCode.$PkgName.obb" -ForegroundColor Green
Write-Host "(or pass -Obb to bundle both into an installable .xapk)" -ForegroundColor Green
}
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""Build a DorkNet-patched Rec Room Quest APK (and optionally an .xapk release).
From a pristine store APK, end to end:
1. hook targets resolve them from an Il2CppDumper dump.cs
(resolve_targets.py), or use a committed table checked
against the APK's libil2cpp.so
2. headers targets_generated.h + config_generated.h
3. compile libdorknet.so (arm64) with the Android NDK
4. apktool decode
5. rename package rename, launcher category, app label
6. inject libdorknet.so in, DT_NEEDED into libmain.so (LIEF)
7. sign apktool build -> zipalign -> apksigner
8. package with --obb: .xapk + one-command installers (or run
`package` on an APK you already built)
Nothing game-derived is written into the repo. You supply the store APK and,
for a release package, the matching OBB. The output contains game binaries
from your own store copy: share it only with people who own the game.
Runs on Windows, Linux and macOS. Needs Python 3.9+ with LIEF, a JDK (java,
keytool), the Android NDK, Android build-tools and apktool.
py -3 build.py build --apk recroom.apk --obb main.2128210.com.AgainstGravity.RecRoom.obb \\
--targets-json ../targets/20230317-2128210.json --keystore dorknet.keystore
py -3 build.py package --apk dorknet-recroom-20230317.apk --obb main.2128210....obb
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import NoReturn
from xml.sax.saxutils import escape as xml_escape
SCRIPTS = Path(__file__).resolve().parent
REPO = SCRIPTS.parent # DorkNet.Quest/
OLD_PKG = "com.AgainstGravity.RecRoom"
SO_NAME = "libdorknet.so"
ON_WINDOWS = os.name == "nt"
def step(msg: str) -> None:
print(f"==> {msg}", flush=True)
def fail(msg: str) -> NoReturn:
raise SystemExit(f"error: {msg}")
def need(path: Path | str, what: str) -> Path:
p = Path(path)
if not p.exists():
fail(f"{what} not found: {p}")
return p
def run(*cmd: object, cwd: Path | None = None) -> None:
args = [str(c) for c in cmd]
r = subprocess.run(args, cwd=cwd)
if r.returncode:
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
def capture(*cmd: object) -> str:
args = [str(c) for c in cmd]
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
if r.returncode:
sys.stderr.write(r.stderr)
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
return r.stdout
def run_py(script: str, *args: object) -> None:
"""Run a sibling helper script with this interpreter (isolated mode)."""
run(sys.executable, "-I", SCRIPTS / script, *args)
def exe(name: str) -> str:
return name + (".exe" if ON_WINDOWS else "")
# ---- tool resolution ------------------------------------------------------
def sdk_root() -> Path | None:
v = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME")
return Path(v) if v else None
def newest_subdir(parent: Path) -> Path | None:
if not parent.is_dir():
return None
dirs = sorted((d for d in parent.iterdir() if d.is_dir()), key=lambda d: d.name, reverse=True)
return dirs[0] if dirs else None
def resolve_build_tools(arg: str | None) -> Path:
if arg:
return need(arg, "build-tools dir")
root = sdk_root()
found = newest_subdir(root / "build-tools") if root else None
if not found:
fail("Android build-tools not found. Pass --build-tools-dir or set ANDROID_SDK_ROOT.")
return found
def resolve_ndk(arg: str | None) -> Path:
if arg:
return need(arg, "NDK")
if os.environ.get("ANDROID_NDK"):
return need(os.environ["ANDROID_NDK"], "NDK (ANDROID_NDK)")
root = sdk_root()
found = newest_subdir(root / "ndk") if root else None
if not found:
fail("Android NDK not found. Pass --ndk-root or set ANDROID_NDK / ANDROID_SDK_ROOT.")
return found
def ndk_bin(ndk: Path) -> Path:
if ON_WINDOWS:
tag = "windows-x86_64"
elif sys.platform == "darwin":
tag = "darwin-x86_64"
else:
tag = "linux-x86_64"
return ndk / "toolchains" / "llvm" / "prebuilt" / tag / "bin"
def resolve_apktool(arg: str | None) -> Path:
if arg:
return need(arg, "apktool.jar")
if os.environ.get("APKTOOL_JAR"):
return need(os.environ["APKTOOL_JAR"], "apktool.jar (APKTOOL_JAR)")
root = sdk_root()
if root and (root / "apktool.jar").exists():
return root / "apktool.jar"
raise SystemExit("error: apktool.jar not found. Pass --apktool-jar or set APKTOOL_JAR.")
def cfg_value(config: Path, key: str) -> str | None:
for line in config.read_text(encoding="utf-8").splitlines():
m = re.match(rf"^\s*{re.escape(key)}\s*=(.*)$", line)
if m:
return m.group(1).split("#", 1)[0].strip()
return None
# ---- build ----------------------------------------------------------------
def cmd_build(a: argparse.Namespace) -> None:
ndk = resolve_ndk(a.ndk_root)
bin_dir = ndk_bin(ndk)
cxx = need(bin_dir / (f"aarch64-linux-android{a.api_level}-clang++" + (".cmd" if ON_WINDOWS else "")),
"NDK aarch64 clang++")
objdump = bin_dir / exe("llvm-objdump")
build_tools = resolve_build_tools(a.build_tools_dir)
zipalign = need(build_tools / exe("zipalign"), "zipalign")
apksigner = need(build_tools / ("apksigner.bat" if ON_WINDOWS else "apksigner"), "apksigner")
apktool = resolve_apktool(a.apktool_jar)
apk = need(a.apk, "store APK").resolve()
config = Path(a.config) if a.config else (
REPO / "dorknet-quest.cfg" if (REPO / "dorknet-quest.cfg").exists() else REPO / "dorknet-quest.cfg.example")
need(config, "config")
print(f"Using config: {config}")
pkg_name = cfg_value(config, "PackageName") or "net.dorknet.RecRoom"
app_name = cfg_value(config, "AppName") or "DorkNet"
work = Path(a.work_dir)
gen, project, extract = work / "gen", work / "project", work / "extract"
for d in (work, gen, extract):
d.mkdir(parents=True, exist_ok=True)
# 1. extract + targets
step("extracting libil2cpp.so + global-metadata.dat")
with zipfile.ZipFile(apk) as z:
for entry in ("lib/arm64-v8a/libil2cpp.so",
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
"lib/arm64-v8a/libmain.so"):
try:
data = z.read(entry)
except KeyError:
fail(f"APK missing {entry}")
(extract / Path(entry).name).write_bytes(data)
il2cpp = extract / "libil2cpp.so"
ver_name, ver_code = "20230317", "2128210"
if a.targets_json:
# A committed, already-resolved table. It is only valid for the exact
# libil2cpp.so it was resolved against, so check that first.
table = need(a.targets_json, "target table")
step(f"using target table {table}")
data = json.loads(table.read_text(encoding="utf-8"))
sha = hashlib.sha256(il2cpp.read_bytes()).hexdigest()
want = str(data.get("il2cppSha256") or "").lower()
if want and sha != want:
fail(f"libil2cpp.so in this APK ({sha}) is not the build the target table was resolved "
f"for ({want}). Resolve a table for it with --dump-cs.")
ver_name, ver_code = str(data["versionName"]), str(data["versionCode"])
else:
if not a.dump_cs:
# Dumping is left to Il2CppDumper (see README) to keep this
# script toolchain-light.
fail("Pass --dump-cs <dump.cs> from Il2CppDumper for this APK, or --targets-json with a "
"resolved table (see DorkNet.Quest/README.md).")
need(a.dump_cs, "dump.cs")
step("resolving hook targets")
# Written into the work dir (Controlled Folder Access can block writes
# under Documents). The repo keeps a committed copy under targets/.
table = work / f"{ver_name}-{ver_code}.json"
run_py("resolve_targets.py", "--dump", a.dump_cs, "--il2cpp", il2cpp,
"--version-name", ver_name, "--version-code", ver_code,
"--objdump", objdump, "--out", table)
# 2. headers
step("generating headers")
run_py("gen_targets_header.py", table, gen / "targets_generated.h")
run_py("gen_config_header.py", config, pkg_name, gen / "config_generated.h")
# 3. compile
step("compiling libdorknet.so (arm64)")
src = REPO / "src"
so = work / SO_NAME
run(cxx, "-std=c++17", "-fPIC", "-shared", "-O2", "-fvisibility=hidden", "-static-libstdc++",
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
f"-I{gen}", f"-I{src}",
src / "dorknet_quest.cpp", src / "config.cpp",
src / "third_party" / "And64InlineHook" / "And64InlineHook.cpp",
"-llog", "-o", so)
need(so, "libdorknet.so")
# 4. decode
step("apktool decode")
if project.exists():
shutil.rmtree(project)
run("java", "-jar", apktool, "decode", "-f", "-o", project, apk)
# 5. package rename + manifest + label
step(f"renaming package {OLD_PKG} -> {pkg_name}")
manifest = project / "AndroidManifest.xml"
m = manifest.read_text(encoding="utf-8").replace(OLD_PKG, pkg_name)
if "android.intent.category.LAUNCHER" not in m:
m = m.replace('<category android:name="android.intent.category.INFO"/>',
'<category android:name="android.intent.category.INFO"/>'
'<category android:name="android.intent.category.LAUNCHER"/>')
manifest.write_text(m, encoding="utf-8", newline="")
# The launcher label comes from @string/app_name; set it in every locale.
step(f"naming the app '{app_name}'")
# XML entities for & < > " ' (the PowerShell version used
# SecurityElement.Escape), so no raw quote reaches Android's string parser.
label = xml_escape(app_name, {'"': "&quot;", "'": "&apos;"})
for sx in (project / "res").rglob("strings.xml"):
s = sx.read_text(encoding="utf-8")
n = re.sub(r'<string name="app_name">[^<]*</string>', lambda _: f'<string name="app_name">{label}</string>', s)
if n != s:
sx.write_text(n, encoding="utf-8", newline="")
# 6. inject
step(f"injecting {SO_NAME}")
shutil.copy2(so, project / "lib" / "arm64-v8a" / SO_NAME)
run_py("inject_needed.py", project / "lib" / "arm64-v8a" / "libmain.so", SO_NAME)
# 7. build + align + sign
step("apktool build")
unsigned = work / "unsigned.apk"
run("java", "-jar", apktool, "build", "-f", "-o", unsigned, project)
if a.keystore:
keystore = need(a.keystore, "keystore")
else:
keystore = work / "dorknet-debug.keystore"
if not keystore.exists():
step("generating a fresh debug keystore (kept in the work dir, never committed)")
run("keytool", "-genkeypair", "-v", "-keystore", keystore, "-alias", a.key_alias,
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
"-storepass", a.keystore_pass, "-keypass", a.keystore_pass,
"-dname", "CN=DorkNet Quest, O=DorkNet")
step("zipalign")
aligned = work / "aligned.apk"
run(zipalign, "-f", "-p", "4", unsigned, aligned)
out_apk = Path(a.out_apk) if a.out_apk else work / f"dorknet-recroom-{ver_name}.apk"
out_apk.parent.mkdir(parents=True, exist_ok=True)
step(f"apksigner -> {out_apk}")
run(apksigner, "sign", "--ks", keystore, "--ks-key-alias", a.key_alias,
"--ks-pass", f"pass:{a.keystore_pass}", "--key-pass", f"pass:{a.keystore_pass}",
"--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
"--out", out_apk, aligned)
capture(apksigner, "verify", "--print-certs", out_apk)
print()
print(f"DONE. Patched APK: {out_apk}")
print(f"Package: {pkg_name} versionCode: {ver_code}")
if a.obb:
print()
package(out_apk, Path(a.obb), Path(a.package_dir) if a.package_dir else None, build_tools)
else:
print("Install it, then push the matching OBB to:")
print(f" /sdcard/Android/obb/{pkg_name}/main.{ver_code}.{pkg_name}.obb")
print("(or pass --obb to bundle both into an installable .xapk)")
# ---- package --------------------------------------------------------------
README_TXT = """{name} {ver} - Rec Room for DorkNet (Meta Quest)
============================================================
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
in over USB and accept "Allow USB debugging" inside the headset.
2. Install. Easiest: open the Quest page of your DorkNet server's website
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
steps. Or use the scripts here:
Windows: double-click install.cmd
macOS / Linux: ./install.sh
The installer needs adb (Android platform-tools) on PATH or in
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
offers to launch it.
3. In the headset: Library > Unknown Sources > {name}.
The app installs as {pkg} alongside any store copy of Rec Room.
SideQuest users can also drop the .xapk onto SideQuest instead.
"""
def package(apk: Path, obb: Path, out_dir: Path | None, build_tools: Path) -> Path:
"""Bundle a patched APK and its OBB into an .xapk plus installers.
The XAPK is the usual APKPure layout (manifest.json + APK + OBB + icon), so
XAPK-aware installers (SideQuest's file drop, SAI, APKPure) can open it too.
The APK and OBB are stored uncompressed: they are already compressed, and
storing them keeps packaging and install fast."""
apk, obb = need(apk, "patched APK").resolve(), need(obb, "OBB").resolve()
aapt2 = need(build_tools / exe("aapt2"), "aapt2")
step("reading APK metadata")
badging = capture(aapt2, "dump", "badging", apk).splitlines()
def badge(pattern: str) -> str | None:
for line in badging:
mm = re.search(pattern, line)
if mm:
return mm.group(1)
return None
pkg = badge(r"^package: name='([^']+)'")
ver_code = badge(r"versionCode='([^']+)'")
ver_name = badge(r"versionName='([^']+)'")
min_sdk = badge(r"^(?:minSdkVersion|sdkVersion):'([^']+)'")
tgt_sdk = badge(r"^targetSdkVersion:'([^']+)'")
label = badge(r"^application-label:'([^']*)'")
if not pkg or not ver_code:
fail("could not read package name / versionCode from the APK")
perms = [mm.group(1) for line in badging if (mm := re.match(r"^uses-permission: name='([^']+)'", line))]
icons = sorted(((int(mm.group(1)), mm.group(2)) for line in badging
if (mm := re.match(r"^application-icon-(\d+):'([^']+)'", line))), reverse=True)
# The launcher icon may be an adaptive-icon XML; XAPK installers want a
# PNG, so fall back to the same-named PNG from the densest mipmap/drawable.
icon_path = None
with zipfile.ZipFile(apk) as z:
names = set(z.namelist())
if icons:
ref = icons[0][1]
if ref.endswith(".png") and ref in names:
icon_path = ref
else:
base = Path(ref).stem
for d in ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi", "ldpi"):
for kind in ("mipmap", "drawable"):
cand = f"res/{kind}-{d}/{base}.png"
if icon_path is None and cand in names:
icon_path = cand
icon_bytes = z.read(icon_path) if icon_path else None
print(f" {pkg} version {ver_name} ({ver_code})")
obb_entry = f"Android/obb/{pkg}/main.{ver_code}.{pkg}.obb"
out_dir = out_dir or apk.parent / f"DorkNet-Quest-{ver_name}"
out_dir.mkdir(parents=True, exist_ok=True)
xapk = out_dir / f"DorkNet-{ver_name}.xapk"
xapk.unlink(missing_ok=True)
manifest = {
"xapk_version": 2,
"package_name": pkg,
"name": label or "DorkNet",
"version_code": ver_code,
"version_name": ver_name,
"min_sdk_version": min_sdk,
"target_sdk_version": tgt_sdk,
"permissions": perms,
"total_size": apk.stat().st_size + obb.stat().st_size,
"expansions": [{"file": obb_entry, "install_location": "EXTERNAL_STORAGE", "install_path": obb_entry}],
}
if icon_bytes:
manifest["icon"] = "icon.png"
step(f"writing {xapk} (the OBB is large; this takes a minute)")
with zipfile.ZipFile(xapk, "w", allowZip64=True) as z:
z.writestr(zipfile.ZipInfo("manifest.json"), json.dumps(manifest, indent=2), compress_type=zipfile.ZIP_DEFLATED)
if icon_bytes:
z.writestr("icon.png", icon_bytes, compress_type=zipfile.ZIP_STORED)
z.write(apk, f"{pkg}.apk", compress_type=zipfile.ZIP_STORED)
z.write(obb, obb_entry, compress_type=zipfile.ZIP_STORED)
step("adding installers")
for f in ("install.ps1", "install.cmd", "install.sh"):
shutil.copy2(SCRIPTS / "installer" / f, out_dir / f)
(out_dir / "install.sh").chmod(0o755)
(out_dir / "README.txt").write_text(
README_TXT.format(name=manifest["name"], ver=ver_name, pkg=pkg), encoding="utf-8")
print()
print(f"DONE. Package folder: {out_dir}")
print(f" {xapk.name} ({xapk.stat().st_size / 2**30:.2f} GB)")
print(f"Install with: {out_dir / 'install.cmd'}")
return out_dir
def cmd_package(a: argparse.Namespace) -> None:
package(Path(a.apk), Path(a.obb), Path(a.out_dir) if a.out_dir else None,
resolve_build_tools(a.build_tools_dir))
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
sub = p.add_subparsers(dest="cmd", required=True)
b = sub.add_parser("build", help="store APK -> signed patched APK (+ .xapk with --obb)")
b.add_argument("--apk", required=True, help="pristine store APK (com.AgainstGravity.RecRoom)")
b.add_argument("--obb", help="matching store OBB; also writes the .xapk release package")
b.add_argument("--config", help="dorknet-quest.cfg (default: DorkNet.Quest/dorknet-quest.cfg, else the .example)")
b.add_argument("--dump-cs", help="Il2CppDumper dump.cs for this APK, to resolve the hook targets")
b.add_argument("--targets-json", help="a resolved target table to use instead of --dump-cs; must match the APK's libil2cpp.so")
b.add_argument("--keystore", help="signing keystore; reuse the same one for every build so updates install in place")
b.add_argument("--keystore-pass", default="dorknet", help="store and key password (default: dorknet)")
b.add_argument("--key-alias", default="dorknet", help="key alias (default: dorknet)")
b.add_argument("--work-dir", default=str(Path(tempfile.gettempdir()) / "dorknet-quest-build"),
help="scratch directory (on Windows keep it outside Controlled Folder Access, e.g. %%TEMP%%)")
b.add_argument("--out-apk", help="where to write the APK (default: <work-dir>/dorknet-recroom-<ver>.apk)")
b.add_argument("--package-dir", help="release folder for the .xapk (default: <apk folder>/DorkNet-Quest-<ver>)")
b.add_argument("--ndk-root", help="Android NDK root (default: ANDROID_NDK, else newest under ANDROID_SDK_ROOT/ndk)")
b.add_argument("--build-tools-dir", help="Android build-tools dir (default: newest under ANDROID_SDK_ROOT/build-tools)")
b.add_argument("--apktool-jar", help="apktool.jar (default: APKTOOL_JAR, else ANDROID_SDK_ROOT/apktool.jar)")
b.add_argument("--api-level", default="24", help="NDK API level (default: 24)")
b.set_defaults(func=cmd_build)
k = sub.add_parser("package", help="patched APK + OBB -> .xapk + installers")
k.add_argument("--apk", required=True)
k.add_argument("--obb", required=True)
k.add_argument("--out-dir", help="release folder (default: <apk folder>/DorkNet-Quest-<ver>)")
k.add_argument("--build-tools-dir")
k.set_defaults(func=cmd_package)
a = p.parse_args()
a.func(a)
if __name__ == "__main__":
main()
@@ -23,6 +23,7 @@ KEYS = {
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
"AutoCreateAccount": ("DN_AUTO_CREATE_ACCOUNT", "bool"),
"LogHttp": ("DN_LOG_HTTP", "bool"),
"Debug": ("DN_DEBUG_HTTP", "bool"),
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
-172
View File
@@ -1,172 +0,0 @@
<#
.SYNOPSIS
Bundle a patched Quest APK and its OBB into one installable package.
.DESCRIPTION
Writes a release folder holding:
DorkNet-<ver>.xapk standard XAPK (manifest.json + APK + OBB + icon)
install.cmd / install.ps1 one-command installer for Windows
install.sh one-command installer for macOS / Linux
README.txt what to do with it
The XAPK is the usual APKPure layout, so XAPK-aware installers (SideQuest's
file drop, SAI, APKPure) can also open it. The OBB is stored uncompressed:
it is already compressed, and storing it keeps packaging and install fast.
The output contains game binaries from your own store copy. Share it only
with people who own the game.
.PARAMETER Apk
The signed, patched APK produced by build.ps1.
.PARAMETER Obb
The store OBB matching the APK's versionCode (main.<code>.com.AgainstGravity.RecRoom.obb).
.PARAMETER OutDir
Release folder to create. Default: <APK folder>\DorkNet-Quest-<ver>.
.PARAMETER BuildToolsDir
Android SDK build-tools dir holding aapt2. Default: resolved from $env:ANDROID_SDK_ROOT.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Apk,
[Parameter(Mandatory = $true)][string]$Obb,
[string]$OutDir,
[string]$BuildToolsDir
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
Need $Apk 'patched APK'
Need $Obb 'OBB'
$Apk = (Resolve-Path -LiteralPath $Apk).Path
$Obb = (Resolve-Path -LiteralPath $Obb).Path
if (-not $BuildToolsDir) {
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
}
$Aapt2 = Join-Path $BuildToolsDir 'aapt2.exe'
Need $Aapt2 'aapt2'
# ---- read the APK's identity --------------------------------------------
Step 'reading APK metadata'
$badging = & $Aapt2 dump badging $Apk 2>$null
if ($LASTEXITCODE) { throw 'aapt2 dump badging failed' }
function Badge($pattern) { foreach ($l in $badging) { if ($l -match $pattern) { return $Matches[1] } } return $null }
$pkg = Badge "^package: name='([^']+)'"
$verCode = Badge "versionCode='([^']+)'"
$verName = Badge "versionName='([^']+)'"
$minSdk = Badge "^(?:minSdkVersion|sdkVersion):'([^']+)'"
$tgtSdk = Badge "^targetSdkVersion:'([^']+)'"
$label = Badge "^application-label:'([^']*)'"
if (-not $pkg -or -not $verCode) { throw 'could not read package name / versionCode from the APK' }
$perms = @($badging | Where-Object { $_ -match "^uses-permission: name='([^']+)'" } | ForEach-Object { $_ -replace "^uses-permission: name='([^']+)'.*", '$1' })
$iconRef = @($badging | Where-Object { $_ -match "^application-icon-\d+:'([^']+)'" } |
ForEach-Object { [pscustomobject]@{ Density = [int]($_ -replace "^application-icon-(\d+):.*", '$1'); Path = ($_ -replace "^application-icon-\d+:'([^']+)'.*", '$1') } } |
Sort-Object Density -Descending | Select-Object -First 1 | ForEach-Object { $_.Path })
# The launcher icon may be an adaptive-icon XML; XAPK installers want a PNG, so
# fall back to the same-named PNG from the densest mipmap/drawable folder.
$iconPath = $null
if ($iconRef) {
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
try {
if ($iconRef -like '*.png' -and $src.GetEntry($iconRef)) { $iconPath = $iconRef }
else {
$base = [System.IO.Path]::GetFileNameWithoutExtension($iconRef)
foreach ($d in 'xxxhdpi', 'xxhdpi', 'xhdpi', 'hdpi', 'mdpi', 'ldpi') {
foreach ($kind in 'mipmap', 'drawable') {
if (-not $iconPath -and $src.GetEntry("res/$kind-$d/$base.png")) { $iconPath = "res/$kind-$d/$base.png" }
}
}
}
} finally { $src.Dispose() }
}
Write-Host " $pkg version $verName ($verCode)"
$obbName = "main.$verCode.$pkg.obb"
$obbEntry = "Android/obb/$pkg/$obbName"
$apkEntry = "$pkg.apk"
if (-not $OutDir) { $OutDir = Join-Path (Split-Path -Parent $Apk) "DorkNet-Quest-$verName" }
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$xapk = Join-Path $OutDir "DorkNet-$verName.xapk"
if (Test-Path $xapk) { Remove-Item -Force $xapk }
# ---- manifest.json -------------------------------------------------------
$manifest = [ordered]@{
xapk_version = 2
package_name = $pkg
name = if ($label) { $label } else { 'DorkNet' }
version_code = $verCode
version_name = $verName
min_sdk_version = $minSdk
target_sdk_version = $tgtSdk
permissions = $perms
total_size = (Get-Item $Apk).Length + (Get-Item $Obb).Length
expansions = @([ordered]@{ file = $obbEntry; install_location = 'EXTERNAL_STORAGE'; install_path = $obbEntry })
}
if ($iconPath) { $manifest.icon = 'icon.png' }
# ---- write the XAPK ------------------------------------------------------
Step "writing $xapk (the OBB is large; this takes a minute)"
$zip = [System.IO.Compression.ZipFile]::Open($xapk, [System.IO.Compression.ZipArchiveMode]::Create)
try {
$e = $zip.CreateEntry('manifest.json', [System.IO.Compression.CompressionLevel]::Optimal)
$w = New-Object System.IO.StreamWriter($e.Open(), (New-Object System.Text.UTF8Encoding($false)))
$w.Write(($manifest | ConvertTo-Json -Depth 5)); $w.Dispose()
if ($iconPath) {
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
try {
$ie = $src.GetEntry($iconPath)
if ($ie) {
$dst = $zip.CreateEntry('icon.png', [System.IO.Compression.CompressionLevel]::NoCompression)
$in = $ie.Open(); $out = $dst.Open(); $in.CopyTo($out); $out.Dispose(); $in.Dispose()
}
} finally { $src.Dispose() }
}
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Apk, $apkEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Obb, $obbEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
} finally { $zip.Dispose() }
# ---- installers + readme -------------------------------------------------
Step 'adding installers'
foreach ($f in 'install.ps1', 'install.cmd', 'install.sh') {
Copy-Item (Join-Path $PSScriptRoot "installer\$f") (Join-Path $OutDir $f) -Force
}
$readme = @"
$($manifest.name) $verName - Rec Room for DorkNet (Meta Quest)
============================================================
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
in over USB and accept "Allow USB debugging" inside the headset.
2. Install. Easiest: open the Quest page of your DorkNet server's website
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
steps. Or use the scripts here:
Windows: double-click install.cmd
macOS / Linux: ./install.sh
The installer needs adb (Android platform-tools) on PATH or in
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
offers to launch it.
3. In the headset: Library > Unknown Sources > $($manifest.name).
The app installs as $pkg alongside any store copy of Rec Room.
SideQuest users can also drop the .xapk onto SideQuest instead.
"@
Set-Content -Path (Join-Path $OutDir 'README.txt') -Value $readme -Encoding utf8
$sizeGb = '{0:N2}' -f ((Get-Item $xapk).Length / 1GB)
Write-Host ""
Write-Host "DONE. Package folder: $OutDir" -ForegroundColor Green
Write-Host " $(Split-Path -Leaf $xapk) ($sizeGb GB)" -ForegroundColor Green
Write-Host "Install with: $(Join-Path $OutDir 'install.cmd')" -ForegroundColor Green
Binary file not shown.
+6 -1
View File
@@ -57,6 +57,11 @@
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
# define DN_ACCEPT_UNSIGNED_IMAGES true
#endif
// A headset with no remembered account: false opens the sign-in page, true
// keeps Rec Room's behaviour of creating a new account automatically.
#ifndef DN_AUTO_CREATE_ACCOUNT
# define DN_AUTO_CREATE_ACCOUNT false
#endif
#ifndef DN_LOG_HTTP
# define DN_LOG_HTTP true
#endif
@@ -82,7 +87,7 @@ void dn_load_runtime_config(char *server_host, size_t server_host_cap,
char *photon_realtime, char *photon_voice, char *photon_chat,
char *photon_server, size_t photon_server_cap, int *photon_port,
char *photon_region, size_t photon_region_cap,
bool *tls_bypass, bool *accept_images,
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
bool *log_http, bool *debug_http, int *body_max);
#endif // DORKNET_QUEST_CONFIG_H
+163 -4
View File
@@ -59,6 +59,7 @@ static int g_photon_port = DN_PHOTON_PORT;
static char g_photon_region[16] = DN_PHOTON_REGION;
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
static bool g_auto_create_account = DN_AUTO_CREATE_ACCOUNT;
static bool g_log_http = DN_LOG_HTTP;
static bool g_debug_http = DN_DEBUG_HTTP;
static int g_body_max = DN_LOG_BODY_MAX;
@@ -89,8 +90,12 @@ static const size_t kArrayDataOff = 0x20;
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
static const unsigned kMsgUserGetUserProof = 578880643u;
static const unsigned kMsgUserGetLoggedInUser = 1131361373u;
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
static const unsigned long long kNoPlatformUserId = 1ULL;
// Stand-in ovrUserHandle for a GetLoggedInUser that Meta refused. Only our
// hooked ovr_User_* accessors ever see it; they never pass it to the SDK.
static void *const kPlaceholderUser = (void *)0x2;
static const size_t kLogChunk = 1500;
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
@@ -103,12 +108,22 @@ typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *na
typedef void *(*il2cpp_object_new_t)(void *klass);
typedef void *(*il2cpp_string_new_t)(const char *str);
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
typedef void *(*il2cpp_domain_get_t)();
typedef void **(*il2cpp_domain_get_assemblies_t)(void *domain, size_t *size);
typedef void *(*il2cpp_assembly_get_image_t)(void *assembly);
typedef const char *(*il2cpp_image_get_name_t)(void *image);
typedef void *(*il2cpp_class_from_name_t)(void *image, const char *ns, const char *name);
static il2cpp_object_get_class_t il2cpp_object_get_class;
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
static il2cpp_object_new_t il2cpp_object_new;
static il2cpp_string_new_t il2cpp_string_new;
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
static il2cpp_domain_get_t il2cpp_domain_get;
static il2cpp_domain_get_assemblies_t il2cpp_domain_get_assemblies;
static il2cpp_assembly_get_image_t il2cpp_assembly_get_image;
static il2cpp_image_get_name_t il2cpp_image_get_name;
static il2cpp_class_from_name_t il2cpp_class_from_name;
static uintptr_t g_il2cpp_base;
@@ -139,6 +154,30 @@ static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *
return (const unsigned char *)((char *)arr + kArrayDataOff);
}
static bool invoke_void(void *obj, const char *method) {
if (!obj) return false;
void *cls = il2cpp_object_get_class(obj);
if (!cls) return false;
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
if (!m) return false;
void *exc = nullptr;
il2cpp_runtime_invoke(m, obj, nullptr, &exc);
return !exc;
}
static void *find_class(const char *image_name, const char *ns, const char *name) {
if (!il2cpp_domain_get || !il2cpp_domain_get_assemblies || !il2cpp_assembly_get_image ||
!il2cpp_image_get_name || !il2cpp_class_from_name) return nullptr;
size_t n = 0;
void **assemblies = il2cpp_domain_get_assemblies(il2cpp_domain_get(), &n);
for (size_t i = 0; assemblies && i < n; i++) {
void *image = il2cpp_assembly_get_image(assemblies[i]);
const char *iname = image ? il2cpp_image_get_name(image) : nullptr;
if (iname && !strcmp(iname, image_name)) return il2cpp_class_from_name(image, ns, name);
}
return nullptr;
}
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
out[0] = '\0';
if (!obj) return;
@@ -438,14 +477,73 @@ typedef unsigned (*ovr_GetType_t)(void *m);
static ovr_IsError_t orig_ovr_IsError;
static ovr_GetType_t ovr_GetType;
static bool is_bypassed_type(unsigned t) {
return t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof ||
t == kMsgUserGetLoggedInUser;
}
// The SDK's Message constructor checks IsError twice: on the message, and --
// when that passes -- on ovr_Message_GetNativeMessage(message). The inner
// message carries the platform's refusal too but doesn't report the outer
// type, so remember the inner handle of a bypassed message (same thread, the
// constructor asks about it immediately) and pass it as well.
static __thread void *t_bypassed_native;
static __thread unsigned t_bypassed_type;
static bool hk_ovr_IsError(void *m) {
bool real = orig_ovr_IsError(m);
if (!real || !ovr_GetType) return real;
unsigned t = ovr_GetType(m);
if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) return false;
if (!real) return real;
if (m && m == t_bypassed_native) return false;
if (ovr_GetType && is_bypassed_type(ovr_GetType(m))) return false;
return real;
}
typedef void *(*ovr_GetNativeMessage_t)(void *m);
static ovr_GetNativeMessage_t orig_ovr_GetNativeMessage;
static void *hk_ovr_GetNativeMessage(void *m) {
void *native = orig_ovr_GetNativeMessage(m);
unsigned t = ovr_GetType ? ovr_GetType(m) : 0;
t_bypassed_native = is_bypassed_type(t) ? native : nullptr;
t_bypassed_type = t;
return native;
}
// OculusPlatformManager.LoadUser asks for the logged-in user; on a headset
// that reaches Meta, Meta refuses it for this re-packaged app, LoadUser
// rejects ("GetLoggedInUser failed") and Initialize shows "Unable to load
// Oculus user account". A headset that can't reach Meta gets a cached user
// and never hits this. Answer a refused request with a placeholder user whose
// ID is kNoPlatformUserId -- the same placeholder the server already treats
// as "no platform account" (PlayerService.IsPlaceholderPlatformId).
static void *(*orig_ovr_Message_GetUser)(void *m);
static void *hk_ovr_Message_GetUser(void *m) {
void *u = orig_ovr_Message_GetUser ? orig_ovr_Message_GetUser(m) : nullptr;
if (u) return u;
// The SDK reads the user from the inner native message
// (GetUser(GetNativeMessage(msg))), so match that handle as well.
bool logged_in_user = (m && m == t_bypassed_native && t_bypassed_type == kMsgUserGetLoggedInUser) ||
(ovr_GetType && ovr_GetType(m) == kMsgUserGetLoggedInUser);
if (!logged_in_user) return u;
LOGI("GetLoggedInUser refused by the platform; using the no-platform placeholder user");
return kPlaceholderUser;
}
// Accessors the SDK's User model reads. Real users pass straight through; the
// placeholder gets empty strings and zero values.
#define DN_USER_STR_ACCESSOR(name) static const char *(*orig_##name)(void *u); static const char *hk_##name(void *u) { if (u == kPlaceholderUser) return ""; return orig_##name ? orig_##name(u) : ""; }
#define DN_USER_VAL_ACCESSOR(name) static uintptr_t (*orig_##name)(void *u); static uintptr_t hk_##name(void *u) { if (u == kPlaceholderUser) return 0; return orig_##name ? orig_##name(u) : 0; }
DN_USER_STR_ACCESSOR(ovr_User_GetDisplayName)
DN_USER_STR_ACCESSOR(ovr_User_GetOculusID)
DN_USER_STR_ACCESSOR(ovr_User_GetImageUrl)
DN_USER_STR_ACCESSOR(ovr_User_GetSmallImageUrl)
DN_USER_STR_ACCESSOR(ovr_User_GetInviteToken)
DN_USER_STR_ACCESSOR(ovr_User_GetPresence)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDeeplinkMessage)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDestinationApiName)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceLobbySessionId)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceMatchSessionId)
DN_USER_VAL_ACCESSOR(ovr_User_GetPresenceStatus) // ovrUserPresenceStatus: 0 = Unknown
static void *(*orig_ovr_GetUserProof)(void *m);
static void *hk_ovr_GetUserProof(void *m) {
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
@@ -469,10 +567,46 @@ static unsigned long long hk_ovr_GetLoggedInUserID() {
static unsigned long long (*orig_ovr_User_GetID)(void *u);
static unsigned long long hk_ovr_User_GetID(void *u) {
if (u == kPlaceholderUser) return kNoPlatformUserId;
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
return real ? real : kNoPlatformUserId;
}
// ------------------------------------------------------------ title screen --
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time auto-login:
// the title screen downloads the headset's remembered accounts and logs into
// one; with none (null) it creates a brand-new account instead
// (PJMGFADOEEM -> grant_type create_account). A server with sign-ups off
// refuses that, the error handler shows the connection-error page and the
// flow tries again, forever. Unless AutoCreateAccount is on, open the sign-in
// page instead (TitleScreenFlowModel.GoToLogin, what the "Log in" button
// does). ManuallyLogin carries the player into the game from there on its own,
// so the boot step gets a promise that never settles.
static void *(*orig_TitleAutoLogin)(void *thiz, void *cached, void *method);
static void *new_pending_promise() {
// MAMJDBJDFAF: the game's non-generic promise (RecRoom.Promises.Runtime);
// a fresh one stays pending.
static void *cls = find_class("RecRoom.Promises.Runtime.dll", "", "MAMJDBJDFAF");
if (!cls) return nullptr;
void *ctor = il2cpp_class_get_method_from_name(cls, ".ctor", 0);
if (!ctor) return nullptr;
void *p = il2cpp_object_new(cls);
void *exc = nullptr;
if (p) il2cpp_runtime_invoke(ctor, p, nullptr, &exc);
return exc ? nullptr : p;
}
static void *hk_TitleAutoLogin(void *thiz, void *cached, void *method) {
if (cached || g_auto_create_account) return orig_TitleAutoLogin(thiz, cached, method);
void *pending = new_pending_promise();
if (!pending || !invoke_void(thiz, "GoToLogin")) {
LOGE("no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
return orig_TitleAutoLogin(thiz, cached, method);
}
LOGI("no remembered account on this headset; opened the sign-in page instead of creating a new account");
return pending;
}
// ----------------------------------------------------------------- init ------
static uintptr_t module_base(const char *name) {
@@ -516,6 +650,12 @@ static bool resolve_il2cpp(void *h) {
SYM(il2cpp_string_new)
SYM(il2cpp_runtime_invoke)
#undef SYM
// Optional: only the title-screen sign-in redirect needs these.
il2cpp_domain_get = (il2cpp_domain_get_t)dlsym(h, "il2cpp_domain_get");
il2cpp_domain_get_assemblies = (il2cpp_domain_get_assemblies_t)dlsym(h, "il2cpp_domain_get_assemblies");
il2cpp_assembly_get_image = (il2cpp_assembly_get_image_t)dlsym(h, "il2cpp_assembly_get_image");
il2cpp_image_get_name = (il2cpp_image_get_name_t)dlsym(h, "il2cpp_image_get_name");
il2cpp_class_from_name = (il2cpp_class_from_name_t)dlsym(h, "il2cpp_class_from_name");
return true;
}
@@ -537,6 +677,8 @@ static void arm_oculus_platform_bypass() {
return;
}
void *a;
if ((a = dlsym(ovr, "ovr_Message_GetNativeMessage")))
A64HookFunction(a, (void *)hk_ovr_GetNativeMessage, (void **)&orig_ovr_GetNativeMessage);
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
if ((a = dlsym(ovr, "ovr_User_GetID")))
@@ -545,6 +687,21 @@ static void arm_oculus_platform_bypass() {
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
if ((a = dlsym(ovr, "ovr_Message_GetUser")))
A64HookFunction(a, (void *)hk_ovr_Message_GetUser, (void **)&orig_ovr_Message_GetUser);
#define DN_HOOK_USER(name) if ((a = dlsym(ovr, #name))) A64HookFunction(a, (void *)hk_##name, (void **)&orig_##name);
DN_HOOK_USER(ovr_User_GetDisplayName)
DN_HOOK_USER(ovr_User_GetOculusID)
DN_HOOK_USER(ovr_User_GetImageUrl)
DN_HOOK_USER(ovr_User_GetSmallImageUrl)
DN_HOOK_USER(ovr_User_GetInviteToken)
DN_HOOK_USER(ovr_User_GetPresence)
DN_HOOK_USER(ovr_User_GetPresenceDeeplinkMessage)
DN_HOOK_USER(ovr_User_GetPresenceDestinationApiName)
DN_HOOK_USER(ovr_User_GetPresenceLobbySessionId)
DN_HOOK_USER(ovr_User_GetPresenceMatchSessionId)
DN_HOOK_USER(ovr_User_GetPresenceStatus)
#undef DN_HOOK_USER
}
static void *init_thread(void *) {
@@ -562,7 +719,7 @@ static void *init_thread(void *) {
g_photon_realtime, g_photon_voice, g_photon_chat,
g_photon_server, sizeof(g_photon_server), &g_photon_port,
g_photon_region, sizeof(g_photon_region),
&g_tls_bypass, &g_accept_images, &g_log_http, &g_debug_http, &g_body_max);
&g_tls_bypass, &g_accept_images, &g_auto_create_account, &g_log_http, &g_debug_http, &g_body_max);
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
@@ -586,6 +743,8 @@ static void *init_thread(void *) {
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
}
arm(&dn_targets.title_auto_login, (void *)hk_TitleAutoLogin, (void **)&orig_TitleAutoLogin);
if (g_accept_images)
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
@@ -111,6 +111,12 @@
"length": 412,
"fingerprint": "0xb12b0044ac4f844f",
"why": "Log-only: prints the quit code and caller."
},
"title_auto_login": {
"rva": 58892048,
"length": 668,
"fingerprint": "0x83bebb9a30983b33",
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
}
}
}
+6
View File
@@ -114,6 +114,12 @@
"class": "public class SessionManager ",
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
"why": "Log-only: prints the quit code and caller."
},
{
"id": "title_auto_login",
"class": "public class TitleScreenFlowModel ",
"method": "private LDGADANDBIO IAKHIJIHAPJ(LBNJFPOLCDL.MDGJJCFGJNF AJCKAHHJDEN)",
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
}
]
}
@@ -0,0 +1,78 @@
using System.Collections.Concurrent;
using System.Text.Json;
using DorkNet.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
namespace DorkNet.Server.Tests;
/// <summary>
/// Stand-in for the notify hub that records every push instead of sending it,
/// so tests can assert on the exact frames the game client would receive.
/// A frame is the hub method plus its single string argument, parsed back into
/// the <c>{Id, Msg}</c> envelope.
/// </summary>
public sealed class CapturingHubContext : IHubContext<NotifyHub>
{
private readonly ConcurrentQueue<SentFrame> _sent = new();
public IHubClients Clients => new CapturingClients(this);
public IGroupManager Groups { get; } = new NoopGroups();
public IReadOnlyList<SentFrame> Sent => _sent.ToArray();
/// <summary>Frames sent to a target such as <c>group:player:12</c>, in
/// order, optionally filtered by envelope <c>Id</c>.</summary>
public IReadOnlyList<SentFrame> To(string target, string? id = null) =>
_sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList();
public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}";
internal void Record(string target, IReadOnlyList<string>? excluded, string method, object?[] args) =>
_sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty<string>(), method,
args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args)));
public sealed record SentFrame(string Target, IReadOnlyList<string> Excluded, string Method, string Payload)
{
public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement;
public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null;
public JsonElement Msg => Envelope.GetProperty("Msg");
}
private sealed class CapturingClients(CapturingHubContext owner) : IHubClients
{
private IClientProxy Proxy(string target, IReadOnlyList<string>? excluded = null) =>
new CapturingProxy(owner, target, excluded);
public IClientProxy All => Proxy("all");
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("all", excludedConnectionIds);
public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}");
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}");
public IClientProxy Group(string groupName) => Proxy($"group:{groupName}");
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
Proxy($"group:{groupName}", excludedConnectionIds);
public IClientProxy Groups(IReadOnlyList<string> groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList());
public IClientProxy User(string userId) => Proxy($"user:{userId}");
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy($"users:{string.Join(",", userIds)}");
}
private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList<string>? excluded) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
owner.Record(target, excluded, method, args);
return Task.CompletedTask;
}
}
private sealed class FanOutProxy(IReadOnlyList<IClientProxy> proxies) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) =>
Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken)));
}
private sealed class NoopGroups : IGroupManager
{
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
@@ -0,0 +1,131 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using DorkNet.Server.Controllers.Site;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace DorkNet.Server.Tests;
/// <summary>The site's downloads proxy: off without config, only on the site's
/// own host, serves only files the manifest lists, and passes Range through so
/// the browser Quest installer can read an .xapk in place.</summary>
public sealed class DownloadsControllerTests
{
private const string Apex = "dorknet.example.com";
private const string Manifest = """
{"releases":[{"id":"2023.03","title":"March 2023","files":[
{"id":"quest","platform":"quest","name":"DorkNet-2023.03-Quest.xapk","path":"2023.03/DorkNet-2023.03-Quest.xapk","size":10,"sha256":"x"}]}]}
""";
[Fact]
public async Task Without_a_base_url_the_manifest_is_404()
{
var (controller, _) = Create(baseUrl: null, _ => throw new InvalidOperationException("no request expected"));
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
}
[Theory]
[InlineData("api.dorknet.example.com")]
[InlineData("cdn.dorknet.example.com")]
public async Task Other_hosts_get_404_without_touching_the_bucket(string host)
{
var (controller, ctx) = Create("http://bucket.internal:3902", _ => throw new InvalidOperationException("no request expected"));
ctx.Request.Host = new HostString(host);
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
}
[Fact]
public void Only_listed_paths_are_found()
{
var manifest = (JsonObject)JsonNode.Parse(Manifest)!;
Assert.NotNull(DownloadsService.FindFile(manifest, "2023.03/DorkNet-2023.03-Quest.xapk"));
Assert.Null(DownloadsService.FindFile(manifest, "manifest.json"));
Assert.Null(DownloadsService.FindFile(manifest, "2023.03/../manifest.json"));
}
[Fact]
public async Task Unlisted_file_is_404_and_never_fetched()
{
var requested = new List<string>();
var (controller, ctx) = Create("http://bucket.internal:3902", req =>
{
requested.Add(req.RequestUri!.AbsolutePath);
return Json(Manifest);
});
await controller.GetFile("index.html", CancellationToken.None);
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
Assert.Equal(["/manifest.json"], requested);
}
[Fact]
public async Task Range_is_forwarded_and_the_partial_response_streamed()
{
HttpRequestMessage? fileRequest = null;
var (controller, ctx) = Create("http://bucket.internal:3902/", req =>
{
if (req.RequestUri!.AbsolutePath == "/manifest.json") return Json(Manifest);
fileRequest = req;
var res = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent("abcd"u8.ToArray()) };
res.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 10);
return res;
}, host: "downloads.example.com");
ctx.Request.Headers.Range = "bytes=2-5";
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
Assert.NotNull(fileRequest);
Assert.Equal("/2023.03/DorkNet-2023.03-Quest.xapk", fileRequest!.RequestUri!.AbsolutePath);
Assert.Equal("bytes=2-5", fileRequest.Headers.Range!.ToString());
Assert.Equal("downloads.example.com", fileRequest.Headers.Host);
Assert.Equal(StatusCodes.Status206PartialContent, ctx.Response.StatusCode);
Assert.Equal("bytes 2-5/10", ctx.Response.Headers.ContentRange.ToString());
Assert.Equal("bytes", ctx.Response.Headers.AcceptRanges.ToString());
Assert.Equal("abcd", Encoding.ASCII.GetString(((MemoryStream)ctx.Response.Body).ToArray()));
}
private static (DownloadsController, HttpContext) Create(
string? baseUrl, Func<HttpRequestMessage, HttpResponseMessage> handler, string? host = null)
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["DorkNet:Downloads:BaseUrl"] = baseUrl,
["DorkNet:Downloads:Host"] = host,
}).Build();
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
var ctx = new DefaultHttpContext();
ctx.Request.Host = new HostString(Apex);
ctx.Response.Body = new MemoryStream();
var controller = new DownloadsController(service, new DomainConfig(Apex))
{
ControllerContext = new ControllerContext { HttpContext = ctx },
};
return (controller, ctx);
}
private static HttpResponseMessage Json(string body) =>
new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private sealed class StubFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(new StubHandler(handler));
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(handler(request));
}
}
@@ -0,0 +1,198 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Controllers.API.Inventory;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace DorkNet.Server.Tests;
/// <summary>
/// Push frames checked against the 2023-03-21 client's notification readers
/// (RecNet.Runtime). Every push is the hub method "Notification" with one
/// JSON string <c>{"Id":"&lt;key&gt;","Msg":{…}}</c>; the client looks the Id
/// up case-sensitively and parses Msg strictly into the handler's DTO, so a
/// wrong key or shape drops the push without any error.
/// </summary>
public sealed class NotificationContractTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public NotificationContractTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Friend_request_pushes_each_side_its_own_relationship_and_an_inbox_message()
{
var (factory, hub) = WithCapturingHub();
using var setup = ApiClient(factory);
var sender = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(factory, sender);
(await client.PostAsync($"/api/relationships/v2/sendfriendrequest?id={target.PlayerId}", null))
.EnsureSuccessStatusCode();
// RelationshipChanged "1" = the Relationship row from each side's view.
var toTarget = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1"));
Assert.Equal("Notification", toTarget.Method);
Assert.Equal(sender.PlayerId, toTarget.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(2, toTarget.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestReceived
Assert.False(toTarget.Msg.TryGetProperty("Reason", out _));
var toSender = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1"));
Assert.Equal(target.PlayerId, toSender.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(1, toSender.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestSent
// MessageReceived "2" — the "X sent you a friend request" inbox row.
var invite = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "2"));
Assert.Equal(4, invite.Msg.GetProperty("Type").GetInt32()); // FriendInvite
Assert.Equal(sender.PlayerId, invite.Msg.GetProperty("FromPlayerId").GetInt64());
using var targetClient = ApiClient(factory, target);
(await targetClient.PostAsync($"/api/relationships/v2/acceptfriendrequest?id={sender.PlayerId}", null))
.EnsureSuccessStatusCode();
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")[^1]
.Msg.GetProperty("RelationshipType").GetInt32());
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")[^1]
.Msg.GetProperty("RelationshipType").GetInt32());
var accepted = hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "2")[^1];
Assert.Equal(40, accepted.Msg.GetProperty("Type").GetInt32()); // FriendRequestAccepted
}
[Fact]
public async Task Favorite_pushes_the_callers_updated_row_to_the_caller_only()
{
var (factory, hub) = WithCapturingHub();
using var setup = ApiClient(factory);
var me = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var other = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(factory, me);
(await client.GetAsync($"/api/relationships/v1/favorite?id={other.PlayerId}")).EnsureSuccessStatusCode();
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(me.PlayerId), "1"));
Assert.Equal(other.PlayerId, frame.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(1, frame.Msg.GetProperty("Favorited").GetInt32());
Assert.Empty(hub.To(CapturingHubContext.PlayerGroup(other.PlayerId), "1"));
}
[Fact]
public async Task Kick_is_the_flat_moderation_block_with_the_reason_in_VoteKickReason()
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.KickPlayerAsync(42, "Too loud", gameSessionId: 777);
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(42), "22"));
var msg = frame.Msg;
// The handler only shows a reason for ReportCategory 10 + !IsHostKick.
Assert.Equal(10, msg.GetProperty("ReportCategory").GetInt32());
Assert.False(msg.GetProperty("IsHostKick").GetBoolean());
Assert.Equal("Too loud", msg.GetProperty("VoteKickReason").GetString());
Assert.Equal(777, msg.GetProperty("GameSessionId").GetInt64());
Assert.Equal(0, msg.GetProperty("Duration").GetInt32());
Assert.False(msg.TryGetProperty("Reason", out _)); // the old nested 2020 shape
}
[Theory]
[InlineData(NotificationService.LogoutReason.SessionTakeover, 2)]
[InlineData(NotificationService.LogoutReason.ForciblyLoggedOut, 3)]
[InlineData(NotificationService.LogoutReason.Banned, 4)]
public async Task Logout_reason_is_the_numeric_enum(NotificationService.LogoutReason reason, int wire)
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.LogoutAsync(43, reason);
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(43), "6"));
var value = frame.Msg.GetProperty("Reason");
Assert.Equal(JsonValueKind.Number, value.ValueKind);
Assert.Equal(wire, value.GetInt32());
}
[Fact]
public async Task Presence_for_subscribers_goes_to_the_subjects_presence_group_minus_excluded_connections()
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.PresenceToSubscribersAsync(55, room: null, isOnline: true, statusVisibility: 0,
excludeConnectionIds: ["self-conn", "friend-conn"]);
var frame = Assert.Single(hub.To($"group:{NotifyHub.PresenceGroupForPlayer(55)}", "PresenceUpdate"));
Assert.Equal(["self-conn", "friend-conn"], frame.Excluded);
Assert.Equal(55, frame.Msg.GetProperty("playerId").GetInt32());
Assert.True(frame.Msg.GetProperty("isOnline").GetBoolean());
Assert.Equal(JsonValueKind.String, frame.Msg.GetProperty("appVersion").ValueKind);
}
[Theory]
[InlineData("""{"PlayerIds":[1,2,3]}""", new long[] { 1, 2, 3 })]
[InlineData("""{"playerIds":["4",5]}""", new long[] { 4, 5 })]
[InlineData("""[6,7]""", new long[] { 6, 7 })]
[InlineData("""{"PlayerIds":[]}""", new long[0])]
public void SubscribeToPlayers_payload_is_read_in_any_casing(string json, long[] expected)
{
Assert.True(NotifyHub.TryReadPlayerIds(JsonDocument.Parse(json).RootElement, out var ids));
Assert.Equal(expected.OrderBy(x => x), ids.OrderBy(x => x));
}
[Fact]
public void Unreadable_SubscribeToPlayers_payload_is_rejected_not_read_as_empty()
{
Assert.False(NotifyHub.TryReadPlayerIds(JsonDocument.Parse("""{"Channels":[]}""").RootElement, out _));
}
[Fact]
public void Consumable_mapping_push_has_exactly_the_clients_keys()
{
var wire = JsonSerializer.SerializeToElement(InventoryController.ConsumableMappingWire(new PlayerInventoryEntity
{
Id = 9,
ItemSlug = "some-consumable",
Quantity = 3,
}));
var keys = wire.EnumerateObject().Select(p => p.Name).ToHashSet();
Assert.Equal(
new HashSet<string> { "Id", "ConsumableItemDesc", "Count", "InitialCount", "CreatedAt", "ActiveDurationMinutes", "IsActive", "IsTransferable" },
keys);
Assert.Equal(JsonValueKind.String, wire.GetProperty("ConsumableItemDesc").ValueKind);
Assert.Equal(3, wire.GetProperty("Count").GetInt32());
}
private (WebApplicationFactory<Program> Factory, CapturingHubContext Hub) WithCapturingHub()
{
var hub = new CapturingHubContext();
var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IHubContext<NotifyHub>>();
services.AddSingleton<IHubContext<NotifyHub>>(hub);
}));
return (factory, hub);
}
private HttpClient ApiClient(WebApplicationFactory<Program> factory, GameClientSession? session = null)
{
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -0,0 +1,133 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// "Publish room" in the 2023 client: RoomModel.TryPublishRoom →
/// <c>PUT roomserver/rooms/{id}/accessibility</c> with form
/// <c>accessibility=1</c> (boxed Int32, NLDBPDCNNCF_NestedType_OMHIIEAKHCH),
/// typed <c>Task&lt;FGCPNAACHIK&gt;</c>. The button only exists when
/// get_CanPublishRoom holds: role ≥ CoOwner, not a dorm, <b>not IsRRO</b>,
/// not already public. A clone of a Rec Room Original template used to be
/// advertised as IsRRO and so could never be published.
/// </summary>
public sealed class RoomPublish2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomPublish2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public void Only_system_owned_originals_are_RRO_on_the_wire()
{
Assert.True(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = PlayerService.SystemAccountId }));
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = 4242 }));
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = false, CreatorPlayerId = PlayerService.SystemAccountId }));
}
[Fact]
public async Task Clone_of_an_original_is_not_RRO_and_can_be_published_by_owner_and_co_owner()
{
using var setup = Client("api");
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var coOwner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var moderator = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var templateId = 9_100_000 + Random.Shared.Next(1, 99_999);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = templateId,
Name = $"Tmpl{Guid.NewGuid():N}"[..18],
CreatorPlayerId = PlayerService.SystemAccountId,
IsAGRoom = true,
Accessibility = 1,
CloningAllowed = false,
ImageName = "image_RecCenter.png",
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
});
db.RoomScenes.Add(new RoomSceneEntity { RoomId = templateId, OrderIndex = 0, Name = "Home" });
await db.SaveChangesAsync();
}
using var ownerRooms = Client("rooms", owner);
var template = await GetJsonAsync(ownerRooms, $"/roomserver/rooms/{templateId}");
Assert.True(template.GetProperty("IsRRO").GetBoolean());
using var cloneResp = await ownerRooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Pub{Guid.NewGuid():N}"[..20] }));
var cloneText = await cloneResp.Content.ReadAsStringAsync();
Assert.True(cloneResp.IsSuccessStatusCode, cloneText);
var clone = JsonDocument.Parse(cloneText).RootElement.GetProperty("value").Clone();
var cloneId = clone.GetProperty("RoomId").GetInt64();
Assert.False(clone.GetProperty("IsRRO").GetBoolean());
Assert.Equal(0, clone.GetProperty("Accessibility").GetInt32());
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true });
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = moderator.PlayerId, Role = RoomRoleRules.StoredModerator, Accepted = true });
await db.SaveChangesAsync();
}
// Publish as the owner: full room details back, now public, still not RRO.
var published = await PutAccessibilityAsync(ownerRooms, cloneId, "1");
Assert.Equal(1, published.GetProperty("Accessibility").GetInt32());
Assert.False(published.GetProperty("IsRRO").GetBoolean());
Assert.True(published.TryGetProperty("SubRooms", out _));
// A moderator may not change accessibility; a co-owner may.
using var modRooms = Client("rooms", moderator);
using (var denied = await modRooms.PutAsync($"/roomserver/rooms/{cloneId}/accessibility",
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = "0" })))
{
// Refusals are 200 {success:false, error} so the client can show them.
using var doc = JsonDocument.Parse(await denied.Content.ReadAsStringAsync());
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
}
using var coRooms = Client("rooms", coOwner);
var unpublished = await PutAccessibilityAsync(coRooms, cloneId, "0");
Assert.Equal(0, unpublished.GetProperty("Accessibility").GetInt32());
}
private static async Task<JsonElement> PutAccessibilityAsync(HttpClient client, long roomId, string value)
{
using var response = await client.PutAsync($"/roomserver/rooms/{roomId}/accessibility",
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = value }));
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {body}");
// "change room accessibility" goes through the {success, value, error}
// helper (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>), same as clone.
using var doc = JsonDocument.Parse(body);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), body);
return doc.RootElement.GetProperty("value").Clone();
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client(string subdomain, GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
+528
View File
@@ -0,0 +1,528 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// 2023 room roles, the co-owner invite flow and the sub-room role-permission
/// matrix, against the client contract:
/// <list type="bullet">
/// <item>Role enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30,
/// TemporaryCoOwner 31, Creator 255. The creator must be listed as 255 —
/// the client takes the local role from the Roles list and only lets a
/// player edit roles strictly below their own, Creator excepted
/// (OMJKHJLFOCO.DIIMLMKEJLC).</item>
/// <item>Invite: owner <c>PUT rooms/{id}/roles/{target}/invite</c> role=30;
/// invitee gets message Type 62 with RoomId; accepts with
/// <c>PUT rooms/{id}/roles/{me}</c> role=30, rejects with
/// <c>PUT rooms/{id}/roles/{me}/invite</c> role=0.</item>
/// <item>Permissions: <c>PUT rooms/{id}/subrooms/{sub}/permissions</c> raw
/// JSON array of {Permission, Role, Override, Type, Value}; read back from
/// <c>GET photon_access_token</c>.</item>
/// </list>
/// </summary>
public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomRoles2023Tests(DorkNetServerFactory factory) => _factory = factory;
// ── pure rules ──────────────────────────────────────────────────────
private static RoomEntity Room(long creator) => new() { Id = 42, CreatorPlayerId = creator };
private static RoomRoleEntity Row(long player, int stored, bool accepted = true) =>
new() { RoomId = 42, PlayerId = player, Role = stored, Accepted = accepted };
[Fact]
public void Creator_is_255_and_listed_once()
{
var room = Room(creator: 7);
var wires = RoomRoleRules.BuildWires(room, new[]
{
Row(7, RoomRoleRules.StoredCoOwner), // a stray row for the creator
Row(8, RoomRoleRules.StoredModerator),
Row(9, RoomRoleRules.StoredCoOwner, accepted: false),
});
Assert.Single(wires, w => w.AccountId == 7);
Assert.Equal(255, wires.Single(w => w.AccountId == 7).Role);
Assert.Equal(20, wires.Single(w => w.AccountId == 8).Role);
var invited = wires.Single(w => w.AccountId == 9);
Assert.Equal(0, invited.Role);
Assert.Equal(30, invited.InvitedRole);
}
[Fact]
public void Creator_may_edit_every_role_including_co_owner_and_creator_permissions()
{
var room = Room(creator: 7);
var rows = Array.Empty<RoomRoleEntity>();
foreach (var role in new[] { 0, 10, 20, 30, 31, 255 })
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 7, role), $"creator role {role}");
}
[Fact]
public void Co_owner_may_edit_only_roles_below_their_own()
{
var room = Room(creator: 7);
var rows = new[] { Row(8, RoomRoleRules.StoredCoOwner), Row(9, RoomRoleRules.StoredModerator) };
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 20));
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 0));
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 30));
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 31));
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 255));
// A moderator edits nothing.
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 9, 0));
// Nor does a stranger.
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 99, 0));
}
[Fact]
public void Role_changes_follow_the_ladder()
{
var room = Room(creator: 7);
var rows = new List<RoomRoleEntity>
{
Row(8, RoomRoleRules.StoredCoOwner),
Row(9, RoomRoleRules.StoredModerator),
Row(10, RoomRoleRules.StoredCoOwner, accepted: false),
};
// Creator: anything on anyone but themselves.
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 8, 0, false, out var k));
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 11, 30, true, out k));
Assert.Equal(RoomRoleRules.ChangeKind.Invite, k);
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 7, 7, 0, false, out _));
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 8, 7, 0, false, out _));
// Co-owner: only below 30, on targets below 30.
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 11, 20, false, out _));
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 9, 10, false, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 11, 30, true, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 10, 0, true, out _)); // invited co-owner
// …and cannot remove a peer co-owner.
Assert.Equal("forbidden", RoomRoleRules.CheckChange(
room, new List<RoomRoleEntity> { Row(8, 0), Row(12, 0) }, 8, 12, 0, false, out _));
// Moderator and strangers manage nobody.
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 11, 10, false, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 99, 11, 10, false, out _));
// Self-service: accept exactly the invited role, decline, leave.
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 30, false, out k));
Assert.Equal(RoomRoleRules.ChangeKind.AcceptInvite, k);
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 0, true, out k));
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
Assert.Null(RoomRoleRules.CheckChange(room, rows, 9, 9, 0, false, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 9, 30, false, out _)); // no invite
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 10, 10, 20, false, out _)); // not the invited role
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 255, false, out _));
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 31, false, out _));
}
[Fact]
public void Permission_matrix_merges_stored_cells_over_defaults()
{
var merged = RoomPermissionMatrix.Merge(new[]
{
new RoomRolePermissionEntity { Id = 1, Role = 30, Permission = "13", Type = 0, Override = true, Value = "false" },
new RoomRolePermissionEntity { Id = 2, Role = 20, Permission = "13", Type = 0, Override = true, Value = "true" },
});
Assert.Equal("false", merged.Single(c => c.Role == 30 && c.Permission == "13").Value);
Assert.Equal("true", merged.Single(c => c.Role == 20 && c.Permission == "13").Value);
Assert.Equal("true", merged.Single(c => c.Role == 255 && c.Permission == "13").Value);
Assert.Equal(RoomPermissionMatrix.Defaults.Count + 1, merged.Count);
}
[Fact]
public void Permission_body_accepts_numbers_strings_and_enum_names()
{
var cells = RoomPermissionMatrix.Parse(
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},""" +
"""{"permission":"CAN_USE_MAKER_PEN","role":"Moderator","override":"true","type":"Bool","value":true},""" +
"""{"Permission":"20","Role":"255","Override":false,"Type":1,"Value":5}]""");
Assert.NotNull(cells);
Assert.Equal(3, cells!.Count);
Assert.Equal(new RoomPermissionMatrix.Cell("13", 30, true, 0, "false"), cells[0]);
Assert.Equal(new RoomPermissionMatrix.Cell("CAN_USE_MAKER_PEN", 20, true, 0, "true"), cells[1]);
Assert.Equal(new RoomPermissionMatrix.Cell("20", 255, false, 1, "5"), cells[2]);
Assert.Null(RoomPermissionMatrix.Parse("role=30"));
}
// ── over HTTP ───────────────────────────────────────────────────────
[Fact]
public async Task Roles_list_reports_the_creator_as_Creator_255()
{
var (owner, _, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var rooms = Client("rooms", owner);
var list = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles");
var me = list.EnumerateArray().Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
Assert.Equal(255, me.GetProperty("Role").GetInt32());
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
var detailsRole = details.GetProperty("Roles").EnumerateArray()
.Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
Assert.Equal(255, detailsRole.GetProperty("Role").GetInt32());
var single = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles/{owner.PlayerId}");
Assert.Equal(255, single.GetProperty("Role").GetInt32());
}
[Fact]
public async Task Co_owner_invite_is_notified_then_accepted_by_the_invitee()
{
var (owner, invitee, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var ownerRooms = Client("rooms", owner);
var invited = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
var inviteeRow = RoleOf(invited, invitee.PlayerId);
Assert.Equal(0, inviteeRow.GetProperty("Role").GetInt32());
Assert.Equal(30, inviteeRow.GetProperty("InvitedRole").GetInt32());
// The invitee's inbox carries message Type 62 (RoomCoOwnerInvited) with RoomId.
long messageId;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var msg = await db.Messages.SingleAsync(m =>
m.RecipientPlayerId == invitee.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited);
Assert.Equal(owner.PlayerId, msg.SenderPlayerId);
Assert.Null(msg.ReadAt);
messageId = msg.Id;
}
// Accept: the invitee PUTs their own role to 30.
using var inviteeRooms = Client("rooms", invitee);
var accepted = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
var acceptedRow = RoleOf(accepted, invitee.PlayerId);
Assert.Equal(30, acceptedRow.GetProperty("Role").GetInt32());
Assert.Equal(0, acceptedRow.GetProperty("InvitedRole").GetInt32());
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.NotNull((await db.Messages.SingleAsync(m => m.Id == messageId)).ReadAt);
Assert.True(await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == invitee.PlayerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner));
}
}
[Fact]
public async Task Invitee_rejects_through_the_invite_route_and_cannot_self_promote()
{
var (owner, invitee, stranger) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var ownerRooms = Client("rooms", owner);
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
using var inviteeRooms = Client("rooms", invitee);
var rejected = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "0"));
Assert.DoesNotContain(rejected.GetProperty("Roles").EnumerateArray(),
e => e.GetProperty("AccountId").GetInt64() == invitee.PlayerId);
// With the invite gone, the same accept is refused.
using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
await AssertRefusedAsync(again);
// A stranger can neither promote themselves nor invite anyone.
using var strangerRooms = Client("rooms", stranger);
using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20"));
await AssertRefusedAsync(self);
using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
await AssertRefusedAsync(other);
}
[Fact]
public async Task Co_owner_manages_roles_below_but_not_co_owners()
{
var (owner, coOwner, target) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
using var coRooms = Client("rooms", coOwner);
var granted = await PutFormAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "20"));
Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32());
using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30"));
await AssertRefusedAsync(inviteCo);
// The creator can directly add and remove a co-owner.
using var ownerRooms = Client("rooms", owner);
var promoted = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "30"));
Assert.Equal(30, RoleOf(promoted, target.PlayerId).GetProperty("Role").GetInt32());
// …and now the co-owner can't touch their peer.
using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
await AssertRefusedAsync(demote);
var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(),
e => e.GetProperty("AccountId").GetInt64() == target.PlayerId);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerAdded));
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerRemoved));
}
[Fact]
public async Task Creator_saves_co_owner_and_creator_permissions_and_reads_them_back()
{
var (owner, coOwner, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
using var ownerRooms = Client("rooms", owner);
var body = """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},{"Permission":"20","Role":255,"Override":true,"Type":0,"Value":"false"}]""";
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", body))
{
var text = await put.Content.ReadAsStringAsync();
Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}");
// {success, value: full room details, error}
var value = Envelope(text);
Assert.Equal(roomId, value.GetProperty("RoomId").GetInt64());
}
// The owner enters the room, then the matrix comes back with the token.
using var match = Client("match", owner);
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
var token = await GetJsonAsync(ownerRooms, "/roomserver/photon_access_token");
var cells = token.GetProperty("Permissions").EnumerateArray().ToList();
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 30
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
&& c.GetProperty("Permission").GetString() == "20").GetProperty("Value").GetString());
Assert.Equal("true", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
// A co-owner may not edit the Co-Owner row, but may edit Moderator.
using var coRooms = Client("rooms", coOwner);
using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]"""))
await AssertRefusedAsync(denied);
using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
Envelope(await allowed.Content.ReadAsStringAsync());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var stored = await db.RoomRolePermissions.Where(p => p.RoomId == roomId).ToListAsync();
Assert.Equal(3, stored.Count);
Assert.All(stored, p => Assert.Equal(0, p.SubRoomId)); // wire 1 → OrderIndex 0
Assert.Equal("false", stored.Single(p => p.Role == 30).Value); // the denied write changed nothing
}
[Fact]
public async Task Invite_pushes_the_message_and_room_and_permission_saves_push_the_access_token()
{
var hub = new CapturingHubContext();
var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions
.RemoveAll<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(services);
services.AddSingleton<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(hub);
}));
HttpClient FactoryClient(string sub, GameClientSession? session = null)
{
var c = factory.CreateClient(new() { AllowAutoRedirect = false });
c.BaseAddress = new Uri($"http://{sub}.{_factory.ApexDomain}");
c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
c.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return c;
}
using var setup = FactoryClient("api");
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var invitee = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = await SeedRoomAsync(owner.PlayerId);
using var ownerRooms = FactoryClient("rooms", owner);
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
var message = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "2"));
Assert.Equal(62, message.Msg.GetProperty("Type").GetInt32());
Assert.Equal(roomId, message.Msg.GetProperty("RoomId").GetInt64());
Assert.Equal((int)owner.PlayerId, message.Msg.GetProperty("FromPlayerId").GetInt32());
var roomPush = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "RoomUpdate"));
Assert.Equal(30, RoleOf(roomPush.Msg, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
using var match = FactoryClient("match", owner);
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
Envelope(await put.Content.ReadAsStringAsync());
var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken"));
Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0);
Assert.Contains(token.Msg.GetProperty("Permissions").EnumerateArray(),
c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21");
}
[Fact]
public void Envelope_filter_wraps_success_and_turns_refusals_into_messages()
{
static JsonElement Body(Microsoft.AspNetCore.Mvc.IActionResult r) =>
JsonDocument.Parse(JsonSerializer.Serialize(((Microsoft.AspNetCore.Mvc.ObjectResult)r).Value)).RootElement.Clone();
var ok = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.OkObjectResult(new { RoomId = 5 })));
Assert.True(ok.GetProperty("success").GetBoolean());
Assert.Equal(5, ok.GetProperty("value").GetProperty("RoomId").GetInt32());
Assert.Equal("", ok.GetProperty("error").GetString());
var forbid = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.ForbidResult()));
Assert.False(forbid.GetProperty("success").GetBoolean());
Assert.False(string.IsNullOrWhiteSpace(forbid.GetProperty("error").GetString()));
var bad = Body(Controllers.RecNetEnvelopeAttribute.Wrap(
new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(new { error = "cannot_change_creator_role" })));
Assert.Equal("cannot_change_creator_role", bad.GetProperty("error").GetString());
// An envelope the handler built itself is left alone.
var already = new Microsoft.AspNetCore.Mvc.OkObjectResult(new { success = false, value = (object?)null, error = "x" });
Assert.Same(already, Controllers.RecNetEnvelopeAttribute.Wrap(already));
}
[Fact]
public async Task Room_mutations_answer_the_success_value_error_envelope_on_bare_and_roomserver_paths()
{
var (owner, invitee, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var rooms = Client("rooms", owner);
// The exact request the client sent in production (bare path, form role=30).
var invited = await PutFormAsync(rooms, $"/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
Assert.Equal(30, RoleOf(invited, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
foreach (var (path, key, value) in new[]
{
($"/rooms/{roomId}/description", "description", "hello"),
($"/roomserver/rooms/{roomId}/accessibility", "accessibility", "0"),
($"/roomserver/rooms/{roomId}/subrooms/1/name", "name", "Lobby"),
($"/roomserver/rooms/{roomId}/subrooms/1/maxplayers", "maxPlayers", "10"),
})
{
var details = await PutFormAsync(rooms, path, (key, value));
Assert.Equal(roomId, details.GetProperty("RoomId").GetInt64());
}
}
// ── helpers ─────────────────────────────────────────────────────────
private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync()
{
using var setup = Client("api");
return (
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain));
}
private async Task<long> SeedRoomAsync(long creatorId, params (long PlayerId, int StoredRole)[] roles)
{
var roomId = 9_300_000 + Random.Shared.Next(1, 99_999);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Roles{Guid.NewGuid():N}"[..18],
CreatorPlayerId = creatorId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 0,
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
});
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = 0, Name = "Home" });
foreach (var (pid, role) in roles)
db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = pid, Role = role, Accepted = true });
await db.SaveChangesAsync();
return roomId;
}
private static JsonElement RoleOf(JsonElement details, long playerId) =>
details.GetProperty("Roles").EnumerateArray()
.Single(e => e.GetProperty("AccountId").GetInt64() == playerId);
private static Task<HttpResponseMessage> PutFormRawAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
=> client.PutAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
private static async Task<JsonElement> PutFormAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
{
using var response = await PutFormRawAsync(client, path, fields);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}");
return Envelope(body);
}
/// <summary>Every room mutation answers the {success, value, error}
/// envelope (NLDBPDCNNCF.BDBHCEGKGDC&lt;FGCPNAACHIK&gt;): assert success and
/// return the room details in <c>value</c>.</summary>
private static JsonElement Envelope(string body)
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.True(root.GetProperty("success").GetBoolean(), body);
Assert.Equal(string.Empty, root.GetProperty("error").GetString());
return root.GetProperty("value").Clone();
}
/// <summary>A refusal is HTTP 200 with success=false and a message: the
/// client shows the message, while a 403/404 surfaces only as the
/// message-less "Exception of type 'CEMNLBKJABA' was thrown".</summary>
private static async Task AssertRefusedAsync(HttpResponseMessage response)
{
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(body);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean(), body);
Assert.False(string.IsNullOrWhiteSpace(doc.RootElement.GetProperty("error").GetString()), body);
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("value").ValueKind);
}
private static Task<HttpResponseMessage> PutJsonRawAsync(HttpClient client, string path, string json)
=> client.PutAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client(string subdomain, GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
+2 -1
View File
@@ -416,9 +416,10 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
}
}
// The entry sub-room (OrderIndex 0) is wire SubRoomId 1 (RoomSceneIds).
private static JsonElement SubRoomZero(JsonElement details) =>
details.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 0);
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
+15 -8
View File
@@ -58,7 +58,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
await db.SaveChangesAsync();
}
// ── in-game: the owner's slider on sub-room 1 only ──────────────────
// ── in-game: the owner's slider on the second sub-room only ─────────
// The client addresses it by its wire SubRoomId, OrderIndex + 1 = 2
// (RoomSceneIds — the client will not load a sub-room whose id is 0).
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
@@ -66,19 +68,23 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
// real client's body went unread.
using var ownerClient = Client("rooms", owner);
using var slider = await ownerClient.PutAsJsonAsync(
$"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
$"/rooms/{roomId}/subrooms/2/maxplayers", new { maxPlayers = 24 });
var sliderBody = await slider.Content.ReadAsStringAsync();
Assert.True(slider.IsSuccessStatusCode,
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
// The client deserialises the reply as FGCPNAACHIK — the room-details
// shape — so the slider's own response has to carry the new value.
var echoed = JsonDocument.Parse(sliderBody).RootElement
// The client reads the reply through the {success, value, error}
// helper with value = FGCPNAACHIK, so the slider's own response has to
// carry the new value inside the envelope.
var envelope = JsonDocument.Parse(sliderBody).RootElement;
Assert.True(envelope.GetProperty("success").GetBoolean(), sliderBody);
var echoed = envelope.GetProperty("value")
.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
.First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
// (the admin API addresses sub-rooms by OrderIndex)
using var adminClient = Client("admin", admin);
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var subRooms = listed.EnumerateArray()
@@ -99,8 +105,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
e => e.GetProperty("MaxPlayers").GetInt32());
Assert.Equal(40, wire[0]);
Assert.Equal(24, wire[1]);
// Wire ids are OrderIndex + 1.
Assert.Equal(40, wire[1]);
Assert.Equal(24, wire[2]);
// The room-level figure is the room's advertised capacity, NOT a copy
// of sub-room 0 — otherwise the admin edit above would have moved it.
@@ -0,0 +1,277 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Sub-room ids and the room instance browser for the 2023 client.
/// <list type="bullet">
/// <item>The client will not load a sub-room whose id is not positive
/// (SubroomModel.DOKCPLJOKIE: <c>RoomId &gt; 0 &amp;&amp; SubroomId &gt; 0</c>),
/// so the wire SubRoomId is OrderIndex + 1 everywhere the client sees it:
/// room details, sub-room routes, matchmaking and the instance browser.</item>
/// <item>A new room always owns a real, named entry sub-room, and a startup
/// pass repairs rooms that lack one.</item>
/// <item><c>GET room/{id}/instances</c> lists live instances with their real
/// players, including the private instance of a private room for the people
/// allowed into it.</item>
/// </list>
/// </summary>
public sealed class SubRoomsAndInstances2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public SubRoomsAndInstances2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public void Wire_sub_room_ids_are_positive_and_round_trip()
{
Assert.Equal(1, RoomSceneIds.ToWire(0));
Assert.Equal(3, RoomSceneIds.ToWire(2));
Assert.Equal(0, RoomSceneIds.ToOrderIndex(1));
Assert.Equal(2, RoomSceneIds.ToOrderIndex(3));
// 0 (cached client state, bare matchmake) is the entry sub-room.
Assert.Equal(0, RoomSceneIds.ToOrderIndex(0));
Assert.Equal(0, RoomSceneIds.ToOrderIndex(-1));
}
[Fact]
public async Task Details_matchmaking_and_sub_room_routes_share_the_wire_id()
{
var owner = await PlayerAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 1, scenes: new[] { "Home", "Arena" });
using var rooms = Client("rooms", owner);
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
var subRooms = details.GetProperty("SubRooms").EnumerateArray().ToList();
Assert.Equal(new long[] { 1, 2 }, subRooms.Select(s => s.GetProperty("SubRoomId").GetInt64()));
Assert.Equal("Home", subRooms[0].GetProperty("Name").GetString());
// matchmake/room/{id}/{sub} takes the wire id and echoes it.
using var match = Client("match", owner);
var arena = await PostFormAsync(match, $"/matchmake/room/{roomId}/2");
Assert.Equal(2, arena.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
var lobby = await PostFormAsync(match, $"/matchmake/room/{roomId}");
Assert.Equal(1, lobby.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
// A sub-room route addressed by wire id 2 edits OrderIndex 1.
using (var rename = await rooms.PutAsync($"/roomserver/rooms/{roomId}/subrooms/2/name",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = "Basement" })))
Assert.True(rename.IsSuccessStatusCode, await rename.Content.ReadAsStringAsync());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.Equal("Basement", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 1)).Name);
Assert.Equal("Home", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 0)).Name);
}
[Fact]
public async Task Cloning_a_template_without_scene_rows_creates_a_named_home_sub_room()
{
var owner = await PlayerAsync();
var templateId = await SeedRoomAsync(1, accessibility: 1, scenes: Array.Empty<string>(), isAg: true);
using var rooms = Client("rooms", owner);
using var resp = await rooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Fresh{Guid.NewGuid():N}"[..20] }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, text);
using var json = JsonDocument.Parse(text);
var value = json.RootElement.GetProperty("value");
var cloneId = value.GetProperty("RoomId").GetInt64();
var sub = value.GetProperty("SubRooms").EnumerateArray().Single();
Assert.Equal(1, sub.GetProperty("SubRoomId").GetInt64());
Assert.Equal("Home", sub.GetProperty("Name").GetString());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == cloneId);
Assert.Equal(0, scene.OrderIndex);
Assert.Equal("Home", scene.Name);
Assert.Equal("a75f7547-79eb-47c6-8986-6767abcb4f92", scene.RoomSceneLocationId);
}
[Fact]
public async Task Startup_repair_gives_scene_less_rooms_a_home_and_names_blank_rows()
{
var bare = await SeedRoomAsync(1, accessibility: 0, scenes: Array.Empty<string>());
var blank = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "", " " });
await using var scope = _factory.Services.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
var (added, renamed) = await service.RepairRoomScenesAsync();
Assert.True(added >= 1);
Assert.True(renamed >= 2);
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var home = await db.RoomScenes.SingleAsync(s => s.RoomId == bare);
Assert.Equal((0, "Home"), (home.OrderIndex, home.Name));
var names = await db.RoomScenes.Where(s => s.RoomId == blank).OrderBy(s => s.OrderIndex).Select(s => s.Name).ToListAsync();
Assert.Equal(new[] { "Home", "Subroom 2" }, names);
// Idempotent.
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
}
[Fact]
public async Task Room_level_restore_also_restores_the_entry_sub_room()
{
var owner = await PlayerAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home", "Back" });
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
await db.RoomScenes.Where(s => s.RoomId == roomId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.DataBlobName, "blob-old"));
db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = roomId, BlobName = "blob-new", UploadedByPlayerId = owner.PlayerId });
await db.SaveChangesAsync();
}
using var api = Client("api", owner);
using var response = await api.PostAsync("/api/rooms/v1/restore", new FormUrlEncodedContent(new Dictionary<string, string>
{
["roomId"] = roomId.ToString(), ["blobName"] = "blob-new",
}));
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
await using var check = _factory.Services.CreateAsyncScope();
var checkDb = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var blobs = await checkDb.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex).Select(s => s.DataBlobName).ToListAsync();
// The entry sub-room follows the restore; other sub-rooms keep theirs.
Assert.Equal(new[] { "blob-new", "blob-old" }, blobs);
}
[Fact]
public async Task Startup_repair_restores_a_missing_entry_sub_room_next_to_others()
{
var roomId = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "Home", "Back" });
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
await db.RoomScenes.Where(s => s.RoomId == roomId && s.OrderIndex == 0).ExecuteDeleteAsync();
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
var (added, _) = await service.RepairRoomScenesAsync();
Assert.True(added >= 1);
var scenes = await db.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex).Select(s => new { s.OrderIndex, s.Name }).ToListAsync();
Assert.Equal(new[] { (0, "Home"), (1, "Back") }, scenes.Select(s => (s.OrderIndex, s.Name)));
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
}
[Fact]
public async Task Instance_browser_lists_the_private_instance_with_its_players_for_allowed_callers()
{
var owner = await PlayerAsync();
var coOwner = await PlayerAsync();
var stranger = await PlayerAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home" });
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true,
});
await db.SaveChangesAsync();
}
using var ownerMatch = Client("match", owner);
var joined = await PostFormAsync(ownerMatch, $"/matchmake/room/{roomId}");
var instanceId = joined.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64();
foreach (var viewer in new[] { owner, coOwner })
{
using var match = Client("match", viewer);
var list = await GetJsonAsync(match, $"/room/{roomId}/instances");
var row = list.EnumerateArray().Single();
Assert.Equal(instanceId, row.GetProperty("roomInstanceId").GetInt64());
Assert.Equal(roomId, row.GetProperty("roomId").GetInt64());
Assert.Equal(1, row.GetProperty("subRoomId").GetInt64());
Assert.False(row.GetProperty("isFull").GetBoolean());
Assert.Equal(new[] { (int)owner.PlayerId },
row.GetProperty("playerIds").EnumerateArray().Select(p => p.GetInt32()));
}
// Someone with no way into the private room is not shown it.
using var strangerMatch = Client("match", stranger);
var hidden = await GetJsonAsync(strangerMatch, $"/room/{roomId}/instances");
Assert.Empty(hidden.EnumerateArray());
}
[Fact]
public void Private_instance_visibility_rule()
{
var row = new PrivateInstanceEntity { Id = 5, OwnerPlayerId = 3 };
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, true, Array.Empty<long>(), row, false));
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, new long[] { 9 }, row, false));
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(3, false, Array.Empty<long>(), row, false));
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, true));
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, false));
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(0, true, Array.Empty<long>(), row, true));
}
// ── helpers ─────────────────────────────────────────────────────────
private async Task<GameClientSession> PlayerAsync()
{
using var setup = Client("api");
return await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
}
private async Task<long> SeedRoomAsync(long creatorId, int accessibility, string[] scenes, bool isAg = false)
{
var roomId = 9_200_000 + Random.Shared.Next(1, 99_999);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Subs{Guid.NewGuid():N}"[..18],
CreatorPlayerId = creatorId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = accessibility,
CloningAllowed = true,
IsAGRoom = isAg,
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
});
for (var i = 0; i < scenes.Length; i++)
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = i, Name = scenes[i] });
await db.SaveChangesAsync();
return roomId;
}
private static async Task<JsonElement> PostFormAsync(HttpClient client, string path)
{
using var response = await client.PostAsync(path, new FormUrlEncodedContent(new Dictionary<string, string>()));
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client(string subdomain, GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -56,9 +56,12 @@ public class FreeGiftsController(DorkNetDbContext db, NotificationService notifi
await db.SaveChangesAsync();
foreach (var row in created)
{
// The full gift package, as GET api/avatar/v2/gifts lists it: the
// client consumes a gift by its Id, which the old hand-built
// payload didn't carry.
await notifications.NotifyAsync(row.RecipientPlayerId,
PushNotificationId.GiftPackageReceived,
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(row.Gift));
}
return Ok(new { Success = true, Sent = created.Count });
@@ -1295,39 +1295,14 @@ public class ImagesController(
.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));
}
/// <summary>Push the new profile image through the same account pushes
/// as every other profile edit: the owner's SelfAccountUpdate and an
/// AccountUpdate to everyone. A hand-built account here left out the
/// emoji, banner, pronouns, identity flags and creation date, so an image
/// change wiped them from the owner's cached account, and nobody else saw
/// the new picture.</summary>
private Task NotifyAccountImageChangedAsync(PlayerEntity player) =>
notifications.AccountChanged(player);
private static string NormaliseTaggedIds(string? csv, long exceptId)
{
@@ -1095,11 +1095,9 @@ public class InventionsController(
});
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 });
// No push: InventionModerationStateChanged ("100") means "your
// inventions' moderation state changed" and takes no payload; sent
// on every cheer it only made the creator refetch for nothing.
}
else if (!req.Cheer && existing is not null)
{
@@ -1256,8 +1254,10 @@ public class InventionsController(
if (!canPush) return Forbid();
var subRoomId = req.SubRoomId;
// Wire SubRoomId (OrderIndex + 1) → the scene's OrderIndex.
var sceneOrderIndex = Services.RoomSceneIds.ToOrderIndex(subRoomId);
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == sceneOrderIndex);
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
if (subRoomDataFilename.Length == 0)
@@ -1271,7 +1271,7 @@ public class InventionsController(
if (subRoomBlob is null)
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
subRoomBlob.SubRoomId ??= subRoomId;
subRoomBlob.SubRoomId ??= sceneOrderIndex;
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
if (roomDataFilename.Length > 0)
@@ -279,11 +279,11 @@ public class InventoryController(
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
await db.SaveChangesAsync();
// A consume always changes an existing mapping, so it is always
// ConsumableMappingRemoved (71) carrying the count that is left;
// Added (70) would make the client treat it as a new stack.
await notifications.NotifyAsync(pid,
row.Quantity > 0
? PushNotificationId.ConsumableMappingAdded
: PushNotificationId.ConsumableMappingRemoved,
new { row.Id, row.ItemSlug, row.Quantity });
PushNotificationId.ConsumableMappingRemoved, ConsumableMappingWire(row));
return Ok(new { row.Id, Count = row.Quantity });
}
@@ -332,8 +332,7 @@ public class InventoryController(
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
await db.SaveChangesAsync();
await notifications.NotifyAsync(recipient,
PushNotificationId.ConsumableMappingAdded,
new { target.Id, target.ItemSlug, target.Quantity });
PushNotificationId.ConsumableMappingAdded, ConsumableMappingWire(target));
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
}
@@ -449,6 +448,26 @@ public class InventoryController(
};
}
/// <summary>ConsumableMapping push payload ("70" Added / "71" Removed):
/// exactly the client's mapping DTO <c>CGCALDBHLGD</c> (formatter
/// <c>JICIOMDBPDO</c>) — Id (Int64), ConsumableItemDesc (STRING, not an
/// object), Count, InitialCount, CreatedAt, ActiveDurationMinutes (Int32?),
/// IsActive, IsTransferable. The old <c>{Id, ItemSlug, Quantity}</c> parsed
/// as a mapping with no item and a count of 0.</summary>
public static object ConsumableMappingWire(PlayerInventoryEntity inv) => new
{
inv.Id,
ConsumableItemDesc = StoreService.TryGetConsumableItemDesc(inv.ItemSlug, out var desc) ? desc : inv.ItemSlug,
Count = inv.Quantity,
InitialCount = inv.Quantity, // the original purchase count isn't tracked
CreatedAt = inv.AcquiredAt,
ActiveDurationMinutes = (int?)null,
inv.IsActive,
// Same as GET api/consumables/v2/getUnlocked, so a push never flips
// what the inventory list says.
IsTransferable = false,
};
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
@@ -152,8 +152,6 @@ public class InGameModerationController(
foreach (var target in ids)
{
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
// Push the ModerationKick shape the 2020.12 client expects:
// Msg.Reason must be a ModerationBlockDetail object.
await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
}
return Ok(new { success = true, error = "" });
@@ -249,21 +247,14 @@ public class InGameModerationController(
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
caller, target, reason, p.BannedUntil, displayReason);
// Push the ban as a ModerationKick (22) with IsBan set, NOT as
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager
// registers only 22, "ModerationUnkick" and 23
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler
// for 24, so that push landed nowhere and the banned player kept playing
// until their next login. The 22 handler reads Msg.Reason as a
// ModerationBlockDetail, which ModerationKickPayload already produces.
await notifications.KickPlayerAsync(
target,
// `reason` is the numeric report category; DisplayReason is the
// moderator's text and is what the ban dialog shows.
string.IsNullOrWhiteSpace(displayReason)
? "You have been banned from this room."
: displayReason.Trim(),
isBan: true);
// Sign the banned player out with Logout "6" (Reason Banned). Not
// ModerationRoomBan (24): the 2023-03-21 client registers no handler
// for it (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335 register only
// 22, "ModerationUnkick" and 23), so that push landed nowhere. Not a
// ModerationKick either: a kick only sends the player to their dorm
// and they kept playing until their next login. The ban check refuses
// that next login.
await notifications.LogoutAsync(target, NotificationService.LogoutReason.Banned);
return Ok(new { success = true, error = "" });
}
@@ -34,6 +34,7 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
public class PlayerEventsController(
DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
PlayerPresenceService presence) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
@@ -393,7 +394,11 @@ public class PlayerEventsController(
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
// The event stores the client's wire SubRoomId; presence and private
// instances hold the OrderIndex (RoomSceneIds).
var subRoomId = extras.SubRoomId is long sub && sub > 0
? RoomSceneIds.ToOrderIndex(sub)
: (long?)null;
// Same sourcing as the room browser: presence rows are the live truth
// (one instance per distinct PhotonRoomId), and the caller's OWN private
@@ -416,7 +421,7 @@ public class PlayerEventsController(
{
roomInstanceId = room.RoomInstanceId,
roomId = room.RoomId,
subRoomId = room.SubRoomId,
subRoomId = RoomSceneIds.ToWire(room.SubRoomId),
// MaxCapacity is the per-instance cap the matchmaker handed
// out, so this is a real answer rather than a constant false.
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
@@ -437,7 +442,7 @@ public class PlayerEventsController(
{
roomInstanceId = p.Id,
roomId = p.RoomId,
subRoomId = p.SubRoomId,
subRoomId = RoomSceneIds.ToWire(p.SubRoomId),
isFull = false,
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
playerIds = Array.Empty<int>(),
@@ -817,7 +822,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras);
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -901,10 +906,7 @@ public class PlayerEventsController(
// Tell the creator someone RSVP'd. They can ignore but the
// watch will refresh their event detail card.
if (ev.CreatorPlayerId != Me)
await notifications.NotifyAsync(ev.CreatorPlayerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, From = Me, body.Response });
await NotifyResponseChangedAsync(ev, row);
// Left as the historical shape on purpose: this route is 2020-only
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
// so there is no 2023 DTO to match here.
@@ -947,10 +949,15 @@ public class PlayerEventsController(
var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null)
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response });
{
existing = new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response };
db.PlayerEventResponses.Add(existing);
}
else
existing.Response = response;
await db.SaveChangesAsync();
var responded = await db.PlayerEvents.FirstAsync(e => e.Id == evt);
await NotifyResponseChangedAsync(responded, existing);
return RespondResult(legacyForm, 0 /*Success*/);
}
@@ -968,9 +975,18 @@ public class PlayerEventsController(
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
await db.PlayerEventResponses
var deleted = await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
if (deleted > 0)
{
// PlayerEventResponseDeleted "84" carries only PlayerEventId.
var creator = await db.PlayerEvents.Where(e => e.Id == evt)
.Select(e => (long?)e.CreatorPlayerId).FirstOrDefaultAsync();
if (creator is long c && c != Me)
await notifications.NotifyAsync(c, PushNotificationId.PlayerEventResponseDeleted,
new { PlayerEventId = evt });
}
return RespondResult(legacyForm, 0 /*Success*/);
}
@@ -993,6 +1009,7 @@ public class PlayerEventsController(
// has to echo the event that was deleted.
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
var notify = await AttendeeIdsAsync(evt);
db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
@@ -1000,6 +1017,7 @@ public class PlayerEventsController(
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
.ExecuteDeleteAsync();
await db.SaveChangesAsync();
await NotifyEventDeletedAsync(id, notify);
return Ok(Wrapper(evt, extras, attendees));
}
@@ -1021,6 +1039,7 @@ public class PlayerEventsController(
evt.Title = Clamp(value.Trim(), 128);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1034,6 +1053,7 @@ public class PlayerEventsController(
evt.Description = Clamp(value.Trim(), 2000);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1056,6 +1076,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1072,6 +1093,7 @@ public class PlayerEventsController(
if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1085,6 +1107,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.ImageName = Clamp(value.Trim(), 128);
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1099,6 +1122,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Accessibility = accessibility;
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1120,6 +1144,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Tags = tags;
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1158,6 +1183,7 @@ public class PlayerEventsController(
?? extras.CanRequestBroadcastPermissions;
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1188,6 +1214,7 @@ public class PlayerEventsController(
}
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1197,6 +1224,7 @@ public class PlayerEventsController(
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
var notify = await AttendeeIdsAsync(ev);
db.PlayerEvents.Remove(ev);
// Cascade-clean the responses; SQLite has no FK cascade
// configured here so do it manually.
@@ -1206,6 +1234,7 @@ public class PlayerEventsController(
await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
.ExecuteDeleteAsync();
await NotifyEventDeletedAsync(eventId, notify);
return Ok();
}
@@ -1244,6 +1273,7 @@ public class PlayerEventsController(
var failed = new List<object>();
var sender = Me;
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
var eventWire = await ToWireOneAsync(ev);
foreach (var rid in req.InvitedPlayerIds.Distinct())
{
var exists = await db.Players.AnyAsync(p => p.Id == rid);
@@ -1252,17 +1282,15 @@ public class PlayerEventsController(
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
continue;
}
db.Messages.Add(new MessageEntity
{
SenderPlayerId = sender,
RecipientPlayerId = rid,
Body = inviteText,
});
await notifications.NotifyAsync(rid,
PushNotificationId.PlayerEventCreated,
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
// The invite as a text message from the host, stored and pushed
// live (it used to be stored only, so it appeared after a relog).
await systemNotifications.SendAsync(rid,
SystemNotificationService.MessageType.TextMessage, fromPlayerId: sender, data: inviteText);
// And the event itself, so it shows in the invitee's event list:
// PlayerEventCreated "80" carries the full event (MDCBEPJCJPO) —
// the same object GET api/playerevents/v2/{id} returns.
await notifications.NotifyAsync(rid, PushNotificationId.PlayerEventCreated, eventWire);
}
await db.SaveChangesAsync();
return Ok(new { FailedInvites = failed, Result = 0 });
}
@@ -1289,21 +1317,56 @@ public class PlayerEventsController(
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
await SaveExtrasAsync(evt, extras);
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
// them to re-fetch instead of sending a chat message.
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
// Attendees' event cards key off BroadcastingRoomInstanceId, so send
// them the updated event.
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
{
var recipients = await db.PlayerEventResponses
/// <summary>Everyone who answered the event with anything but NotGoing,
/// except its creator.</summary>
private async Task<List<long>> AttendeeIdsAsync(PlayerEventEntity evt) =>
(await db.PlayerEventResponses
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
.Select(r => r.PlayerId)
.Distinct()
.ToListAsync();
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
.ToListAsync())
.Where(p => p != evt.CreatorPlayerId)
.ToList();
/// <summary>PlayerEventUpdated "81" to the attendees, carrying the full
/// event (MDCBEPJCJPO, formatter CENEMCMGDKG) — the same object
/// GET api/playerevents/v2/{id} returns; the client replaces its cached
/// event with it. The old <c>{PlayerEventId, Title}</c> overwrote the
/// cached event with an empty name and default times, and the
/// PlayerEventStateChanged (85) used for broadcasts has no client handler
/// at all.</summary>
private async Task NotifyEventUpdatedAsync(PlayerEventEntity evt)
{
var recipients = await AttendeeIdsAsync(evt);
if (recipients.Count == 0) return;
var wire = await ToWireOneAsync(evt);
foreach (var playerId in recipients)
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventUpdated, wire);
}
/// <summary>PlayerEventDeleted "82" ({PlayerEventId}) to the players who
/// were attending, collected before the rows were removed.</summary>
private async Task NotifyEventDeletedAsync(long eventId, IEnumerable<long> recipients)
{
foreach (var playerId in recipients)
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventDeleted,
new { PlayerEventId = eventId });
}
/// <summary>PlayerEventResponseChanged "83" to the event's creator:
/// <c>{PlayerEvent, PlayerEventResponse}</c> (COAGAJPELCG), each the same
/// object the event and response REST routes return.</summary>
private async Task NotifyResponseChangedAsync(PlayerEventEntity evt, PlayerEventResponseEntity response)
{
if (evt.CreatorPlayerId == response.PlayerId) return;
await notifications.NotifyAsync(evt.CreatorPlayerId, PushNotificationId.PlayerEventResponseChanged,
new { PlayerEvent = await ToWireOneAsync(evt), PlayerEventResponse = ResponseWire(response) });
}
// ── Report ───────────────────────────────────────────────────────────
@@ -48,6 +48,7 @@ public class PlaylistsController(
/// query or JSON body so JSON callers (admin UI) keep working.</summary>
[HttpPost("/playlists")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> Create()
{
var name = await ReadFieldAsync("name", "Name");
@@ -87,6 +88,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistName(long playlistId)
{
var value = await ReadFieldAsync("name", "Name", "value", "Value");
@@ -96,6 +98,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistDescription(long playlistId)
{
var value = await ReadFieldAsync("description", "Description", "value", "Value");
@@ -105,6 +108,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistImage(long playlistId)
{
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
@@ -116,6 +120,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistTags(long playlistId)
{
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
@@ -131,6 +136,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPut("/playlists/{playlistId:long}/visibility")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
{
var value = await ReadFieldAsync(
@@ -144,6 +150,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
{
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
@@ -156,6 +163,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPut("/playlists/{playlistId:long}/restrictions")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
{
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
@@ -174,6 +182,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/warning")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistWarning(long playlistId)
{
var mask = await ReadFieldAsync("warningMask", "WarningMask");
@@ -295,6 +304,7 @@ public class PlaylistsController(
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
{
try
@@ -307,6 +317,7 @@ public class PlaylistsController(
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
{
try
@@ -48,7 +48,11 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// </summary>
[ApiController]
[Authorize]
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase
public class RelationshipsController(
DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
ServerSettingsService settings) : ControllerBase
{
// RelationshipType — int values match Relationship.RelationshipType
// in the patched dump.cs.
@@ -226,7 +230,9 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
}
await db.SaveChangesAsync();
await notifications.FriendRequestReceived(id, me);
await PushPairAsync(me, id);
// The "X sent you a friend request" row in the target's notifications.
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendInvite, fromPlayerId: me);
return Ok(BuildRelationship(row, me));
}
@@ -251,7 +257,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
rel.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await notifications.FriendRequestAccepted(id, me);
await PushPairAsync(me, id);
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
return Ok(BuildRelationship(rel, me));
}
@@ -285,7 +292,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
db.Relationships.RemoveRange(removable);
await db.SaveChangesAsync();
await notifications.FriendRemoved(id, me);
await PushPairAsync(me, id);
var remaining = pair.Except(removable).ToList();
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
@@ -333,6 +340,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
existing.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
await PushPairAsync(me, id);
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
return Ok(BuildRelationship(existing, me));
}
@@ -411,6 +420,10 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
if (ignored is bool i) row.Ignored = i;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// The HTTP reply is only an ack; the updated row reaches the client
// through RelationshipChanged. A preference is the caller's own bit,
// so only the caller is told.
await PushPairAsync(me, otherId, notifyOther: false);
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
}
@@ -511,6 +524,36 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
// ── helpers ──────────────────────────────────────────────────────
/// <summary>Push RelationshipChanged for the pair (<paramref name="me"/>,
/// <paramref name="other"/>) after a change, each side getting its OWN view
/// of the relationship. Without this a friend request, accept or unfriend
/// only showed up after the friends list was refetched, and the client
/// never re-subscribed to the new friend's presence.</summary>
private async Task PushPairAsync(long me, long other, bool notifyOther = true)
{
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == other) ||
(r.RequesterId == other && r.TargetId == me))
.ToListAsync();
var globalFriends = await settings.IsGlobalFriendsEnabledAsync();
await notifications.RelationshipChanged(me, ViewOf(me, other, pair, globalFriends));
if (notifyOther)
await notifications.RelationshipChanged(other, ViewOf(other, me, pair, globalFriends));
}
/// <summary>The relationship from <paramref name="viewer"/>'s side, the way
/// <c>v2/get</c> would list it.</summary>
private static Dictionary<string, object> ViewOf(
long viewer, long other, List<RelationshipEntity> pair, bool globalFriends)
{
if (globalFriends
&& other != RelationshipQueries.SystemAccountId
&& !pair.Any(r => r.Status == EntityStatus.Blocked))
return SynthFriend(other, pair.FirstOrDefault(r => r.RequesterId == viewer), viewer);
return pair.Count == 0 ? EmptyRelationship(other) : BuildMerged(viewer, other, pair);
}
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
{
var rows = await db.Relationships
@@ -35,6 +35,7 @@ public class RoomsController(
DomainConfig domain,
NotificationService notifications,
ServerSettingsService serverSettings,
SystemNotificationService systemNotifications,
ILogger<RoomsController> logger) : ControllerBase
{
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
@@ -574,6 +575,7 @@ public class RoomsController(
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
@@ -586,7 +588,7 @@ public class RoomsController(
.Take(50)
.Select(b => new
{
SubRoomId = subRoomId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
DataBlob = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId,
CreatedAt = b.UploadedAt,
@@ -613,6 +615,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -661,6 +664,7 @@ public class RoomsController(
[Authorize]
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -689,6 +693,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -731,6 +736,7 @@ public class RoomsController(
long roomId, long subRoomId,
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -763,7 +769,7 @@ public class RoomsController(
saves.Add(new
{
SubRoomDataSaveId = currentSaveId,
SubRoomId = subRoomId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -809,7 +815,7 @@ public class RoomsController(
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
return new
{
SubRoomId = (long)orderIndex,
SubRoomId = RoomSceneIds.ToWire(orderIndex),
RoomId = room.Id,
Name = scene?.Name ?? "Home",
DataBlob = dataBlob,
@@ -824,7 +830,7 @@ public class RoomsController(
CurrentSave = new
{
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
SubRoomId = (long)orderIndex,
SubRoomId = RoomSceneIds.ToWire(orderIndex),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -871,7 +877,7 @@ public class RoomsController(
return new
{
SubRoomDataSaveId = saveId,
SubRoomId = subRoomId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = unityAssetId,
SubRoomUnityAssetId = unityAssetId,
ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -916,13 +922,14 @@ public class RoomsController(
public async Task<IActionResult> SubRoomRestoreData(
long roomId, long subRoomId, [FromForm] string? filename)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
if (string.IsNullOrWhiteSpace(filename))
return BadRequest(new { error = "missing_filename" });
var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var blob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
@@ -1399,6 +1406,7 @@ public class RoomsController(
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(room.Id, blob.BlobName);
await db.SaveChangesAsync();
// Push the same SubscriptionUpdateRoom the save flow sends so
@@ -1472,10 +1480,27 @@ public class RoomsController(
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(roomId, blob.BlobName);
await db.SaveChangesAsync();
return Ok(new { restored = blob.BlobName });
}
/// <summary>A room-level restore also restores the entry sub-room
/// (OrderIndex 0): room details serve the entry scene's own blob ahead
/// of the room's, so updating only <c>Rooms.CurrentDataBlobName</c>
/// would leave the old version in place. The per-sub-room restore keeps
/// the two in step the other way round. Dorms keep their blob per
/// player (DormState) instead.</summary>
private Task SyncEntrySceneBlobAsync(long roomId, string blobName)
{
var now = DateTime.UtcNow;
return db.RoomScenes
.Where(s => s.RoomId == roomId && s.OrderIndex == 0)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.DataBlobName, blobName)
.SetProperty(x => x.DataModifiedAt, now));
}
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
/// per-caller bookmark/cheer flags for one room. Wire shape verified
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
@@ -2001,7 +2026,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = (long)s.OrderIndex,
SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
RoomId = room.Id,
Name = s.Name,
UnityAssetId = s.StudioUnityAssetId,
@@ -2027,7 +2052,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = 0L,
SubRoomId = RoomSceneIds.ToWire(0),
RoomId = room.Id,
Name = "Home",
UnityAssetId = string.Empty,
@@ -2046,11 +2071,10 @@ public class RoomsController(
},
};
var wireRoles = new List<object>
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
// The creator is Creator (255), never CoOwner (30): the client reads
// the local player's role from this list and only lets a player edit
// roles strictly below their own (see RoomRoleRules).
var wireRoles = RoomRoleRules.BuildWires(room, roleList);
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
@@ -2075,7 +2099,9 @@ public class RoomsController(
State = room.State,
Accessibility = room.Accessibility,
SupportsLevelVoting = room.SupportsLevelVoting,
IsRRO = room.IsAGRoom,
// Player rooms are never RRO on the wire (see RoomService.WireIsRRO):
// the client refuses publish and room settings for an RRO.
IsRRO = RoomService.WireIsRRO(room),
SupportsScreens = room.SupportsScreens,
SupportsWalkVR = room.SupportsWalkVR,
SupportsTeleportVR = room.SupportsTeleportVR,
@@ -2904,6 +2930,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RenameRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
@@ -2922,9 +2949,9 @@ public class RoomsController(
{
// RoomRoles co-owner check — same authorisation as the
// existing modify endpoint, lets co-owners rename too.
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
// Room settings are creator / co-owner only; moderators and
// hosts moderate players, they don't edit the room.
if (!await CanEditRoomAsync(room, me)) return Forbid();
}
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
@@ -2959,11 +2986,13 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
@@ -2976,9 +3005,9 @@ public class RoomsController(
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
if (room.CreatorPlayerId != me)
{
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
// Room settings are creator / co-owner only; moderators and
// hosts moderate players, they don't edit the room.
if (!await CanEditRoomAsync(room, me)) return Forbid();
}
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
@@ -3607,6 +3636,7 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/subrooms")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> AddSubRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
@@ -3614,7 +3644,7 @@ public class RoomsController(
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length == 0) name = "NewScene";
@@ -3649,12 +3679,14 @@ public class RoomsController(
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
@@ -3710,20 +3742,6 @@ public class RoomsController(
/// <summary>One row of the per-subroom role-permission matrix the 2023
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
public sealed class SubRoomPermissionEntry
{
// Field order + CLR types come from the BNLPBMJJOMM constructor
// (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role,
// Boolean Override, FEOJFGECKGH Type, String Value). The two enum
// members serialize as numbers (the role writer boxes them
// System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here.
public string? Permission { get; set; }
public int? Role { get; set; }
public bool? Override { get; set; }
public int? Type { get; set; }
public string? Value { get; set; }
}
public sealed class PublishSubRoomSaveRequest
{
public long? SubRoomDataSaveId { get; set; }
@@ -3746,6 +3764,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> SubRoomMaxPlayers(
long roomId,
long subRoomId,
@@ -3763,6 +3782,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> SubRoomAccessibility(
long roomId,
long subRoomId,
@@ -3773,45 +3793,120 @@ public class RoomsController(
s.CanMatchmakeInto = a != AccessibilityPrivate;
});
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the
/// role-permission matrix. Client:
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — "change
/// subroom permissions", the role-permission matrix. Client:
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList&lt;BNLPBMJJOMM&gt;)</c>
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c>
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON
/// deserialize into the List).
/// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room
/// mutation, so the response is the full <c>FGCPNAACHIK</c> details).
/// Unlike its siblings the body is NOT form-encoded: the closure calls
/// <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> (CCNOMNNHNNG.txt ISIL 009), a raw
/// JSON array of cells. The editor sends one changed cell per call
/// (OMJKHJLFOCO.AFHJLBBFAGP.&lt;LocalRequestModifyRolePermission&gt;g__ChangeAndRefresh
/// builds a one-element BNLPBMJJOMM[]), so each cell is upserted; cells not
/// sent are left alone.
///
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no
/// column for it and there is no permissions table. Adding one means an
/// entity + DbContext + migration, which is outside this file. Until then
/// the matrix is accepted, logged, and the room details echoed back so the
/// client's promise resolves instead of 415-ing; the values do not survive
/// a round-trip. The old handler silently bound an unrelated DTO whose keys
/// the client never sends, which was strictly worse.</para></summary>
/// <para>Read back through <c>GET photon_access_token</c> (its
/// <c>Permissions</c> list), and pushed to everyone in that sub-room as
/// the <c>PhotonAccessToken</c> hub message so the change takes effect
/// without a rejoin.</para>
///
/// <para>Authorization mirrors the client's own rule
/// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including
/// Co-Owner and Creator; a co-owner only the roles below their own;
/// nobody else may edit (<see cref="RoomRoleRules.CanEditRolePermissions"/>).</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
public async Task<IActionResult> SubRoomPermissions(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
[RecNetEnvelope]
public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var roleRows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
if (!RoomRoleRules.CanEditRoom(room, roleRows, pid)) return Forbid();
string body;
using (var reader = new StreamReader(Request.Body))
body = await reader.ReadToEndAsync();
var cells = RoomPermissionMatrix.Parse(body);
if (cells is null) return BadRequest(new { error = "invalid_permissions_body" });
var denied = cells.Where(c => !RoomRoleRules.CanEditRolePermissions(room, roleRows, pid, c.Role)).ToList();
if (denied.Count > 0)
{
logger.LogWarning(
"[subroom-permissions] denied room={Room} sub={Sub} by={By} roles={Roles}",
roomId, subRoomId, pid, string.Join(",", denied.Select(d => d.Role).Distinct()));
return Forbid();
}
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
await db.SaveChangesAsync();
logger.LogWarning(
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)",
roomId, subRoomId, pid, req?.Count ?? 0);
var now = DateTime.UtcNow;
scene.DataModifiedAt = now;
for (var attempt = 1; ; attempt++)
{
var existing = await db.RoomRolePermissions
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
.ToListAsync();
foreach (var cell in cells)
{
// Exact match, like the unique index on
// (RoomId, SubRoomId, Role, Permission).
var row = existing.FirstOrDefault(p =>
p.Role == cell.Role && string.Equals(p.Permission, cell.Permission, StringComparison.Ordinal));
if (row is null)
{
row = new RoomRolePermissionEntity
{
RoomId = roomId,
SubRoomId = subRoomId,
Role = cell.Role,
Permission = cell.Permission,
};
db.RoomRolePermissions.Add(row);
existing.Add(row);
}
row.Type = cell.Type;
row.Override = cell.Override;
row.Value = cell.Value;
row.UpdatedByPlayerId = pid;
row.UpdatedAt = now;
}
try
{
await db.SaveChangesAsync();
break;
}
catch (DbUpdateException) when (attempt < 3)
{
// Another editor inserted the same new cell between our read
// and our insert (unique index). Forget our pending rows and
// apply the cells again, now as updates of theirs.
foreach (var entry in db.ChangeTracker.Entries<RoomRolePermissionEntity>().ToList())
entry.State = EntityState.Detached;
}
}
logger.LogInformation(
"[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",
roomId, subRoomId, pid, cells.Count);
// Everyone in that sub-room gets the new matrix; the client's
// PhotonAccessToken handler re-initialises its permissions from it.
var matrix = await RoomPermissionMatrix.LoadAsync(db, roomId, subRoomId);
foreach (var (instance, players) in presence.EnumerateActiveInstances(roomId, includePrivate: true))
{
if (instance.SubRoomId != subRoomId) continue;
var tokenBody = RoomPermissionMatrix.AccessTokenBody(instance.RoomInstanceId, matrix);
foreach (var other in players)
await notifications.NotifyTypedAsync(other, "PhotonAccessToken", tokenBody);
}
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
@@ -3840,12 +3935,14 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
@@ -3919,12 +4016,14 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var src = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (src is null) return NotFound();
@@ -3971,6 +4070,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> ModifySubRoom(
long roomId,
long subRoomId,
@@ -4010,11 +4110,13 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> MoveSubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
@@ -4105,6 +4207,7 @@ public class RoomsController(
[Authorize]
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var body = await ReadSaveRoomSceneRequestAsync();
body.RoomSceneId = subRoomId;
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
@@ -4165,10 +4268,11 @@ public class RoomsController(
/// successful mutation surface as a client-side parse failure.</summary>
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
mutator(scene);
@@ -4219,12 +4323,7 @@ public class RoomsController(
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var list = new List<object>(rows.Count + 1)
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
return Ok(list);
return Ok(RoomRoleRules.BuildWires(room, rows));
}
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
@@ -4339,30 +4438,38 @@ public class RoomsController(
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task&lt;EFHPLDPNGIM&gt;</c>,
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
/// above.</summary>
/// above. The creator answers as Creator (255).</summary>
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId == playerId)
return Ok(BuildRoomAccountRoleWire(playerId, 30));
var row = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
if (row is null) return NotFound();
return Ok(BuildRoomRoleGrantWire(row));
var rows = await db.RoomRoles
.Where(r => r.RoomId == roomId && r.PlayerId == playerId)
.ToListAsync();
var wire = RoomRoleRules.BuildWires(room, rows)
.FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
if (wire is null) return NotFound();
return Ok(wire);
}
public sealed class GrantRoleRequest { public int? Role { get; set; } }
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role",
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
/// <c>FGCPNAACHIK</c> details — which this already does. Only the
/// roomserver/ twin was missing.</summary>
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — "change room account
/// role" (<c>NLDBPDCNNCF.LMIEIJMCEAB</c>, NLDBPDCNNCF.txt:7516, verb 3 =
/// PUT, single form key <c>role</c> boxed as Int32 —
/// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
/// <c>FGCPNAACHIK</c> details. Three callers:
/// <list type="bullet">
/// <item>the owner's role button (AddRemovePlayerRoomRoleConditionalButton
/// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
/// role 0;</item>
/// <item>the invitee accepting a co-owner invite (message Type 62,
/// KPFKPIKDBFJ.ELKAEBMMIGL / KLNLKDJJDLE Accept): their OWN account id
/// with role 30;</item>
/// <item>a co-owner rejecting a direct add (Type 60, COJCABEJDFN Reject):
/// their own id with role 0.</item>
/// </list>
/// Authorization is <see cref="RoomRoleRules.CheckChange"/>.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
@@ -4372,45 +4479,20 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[Authorize]
public async Task<IActionResult> GrantRole(long roomId, long playerId)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
[RecNetEnvelope]
public Task<IActionResult> GrantRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
return BadRequest(new { error = "invalid_role", role });
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
if (existing is null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = true, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = true;
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
/// as grant but Accepted=false; the target's accept-invite flow
/// flips the flag (separate endpoint, not yet exposed). For now
/// invited rows surface in RoomDetails.InvitedCoOwners etc.
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
/// <summary>PUT <c>rooms/{id}/roles/{playerId}/invite</c> — "invite account
/// to role" (<c>NLDBPDCNNCF.KIJDCLINOIC</c>, NLDBPDCNNCF.txt:7686, verb 3,
/// form key <c>role</c> as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
/// Only Co-Owner goes through an invite in this client: the owner's button
/// sends role 30 here, the invitee is told with a Type 62
/// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
/// <c>PUT roles/{me}</c> role 30 or rejects by sending role 0 back to THIS
/// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
/// which clears the pending invite but leaves any role they already
/// hold.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
@@ -4420,62 +4502,168 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[Authorize]
public async Task<IActionResult> InviteRole(long roomId, long playerId)
[RecNetEnvelope]
public Task<IActionResult> InviteRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
private async Task<IActionResult> ChangeRoomAccountRoleAsync(long roomId, long playerId, bool isInvite)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
var requested = await ReadClientRoomRoleAsync();
if (requested is null) return BadRequest(new { error = "missing_role" });
if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
return NotFound(new { error = "unknown_player" });
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
return BadRequest(new { error = "invalid_role", role });
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
if (existing is null)
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var error = RoomRoleRules.CheckChange(
room, rows, me, playerId, requested.Value, isInvite, out var kind);
if (error is not null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
logger.LogWarning(
"[room-roles] denied room={Room} actor={Actor} target={Target} role={Role} invite={Invite} error={Error}",
roomId, me, playerId, requested.Value, isInvite, error);
return error == "cannot_change_creator_role" || error == "invalid_role"
? BadRequest(new { error, role = requested.Value })
: Forbid();
}
var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
var hadCoOwnerInvite = targetRows.Any(r => !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
RoomRoleRules.TryFromWire(requested.Value, out var storedRole);
switch (kind)
{
case RoomRoleRules.ChangeKind.Clear when isInvite:
// Reject / withdraw an invite: only the pending rows go.
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted));
break;
case RoomRoleRules.ChangeKind.Clear:
db.RoomRoles.RemoveRange(targetRows);
break;
case RoomRoleRules.ChangeKind.Invite:
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = false, GrantedByPlayerId = me,
});
// Already holding the role: nothing to invite to. Otherwise
// replace any other pending invite but keep the role the
// player holds until they accept.
if (targetRows.Any(r => r.Accepted && r.Role == storedRole)) break;
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted && r.Role != storedRole));
var pending = targetRows.FirstOrDefault(r => !r.Accepted && r.Role == storedRole);
if (pending is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
});
}
else
{
pending.GrantedByPlayerId = me;
pending.GrantedAt = DateTime.UtcNow;
}
break;
}
case RoomRoleRules.ChangeKind.AcceptInvite:
case RoomRoleRules.ChangeKind.Grant:
{
// One role per account: the new one replaces the rest
// (an accepted invite replaces the role held meanwhile).
var keep = targetRows.FirstOrDefault(r => r.Role == storedRole);
db.RoomRoles.RemoveRange(targetRows.Where(r => !ReferenceEquals(r, keep)));
if (keep is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = true,
GrantedByPlayerId = kind == RoomRoleRules.ChangeKind.AcceptInvite
? targetRows.FirstOrDefault(r => !r.Accepted)?.GrantedByPlayerId ?? me
: me,
GrantedAt = DateTime.UtcNow,
});
}
else
{
keep.Accepted = true;
if (kind == RoomRoleRules.ChangeKind.Grant) keep.GrantedByPlayerId = me;
keep.GrantedAt = DateTime.UtcNow;
}
break;
}
}
else
await db.SaveChangesAsync();
var isCoOwnerNow = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
var hasCoOwnerInviteNow = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
logger.LogInformation(
"[room-roles] room={Room} actor={Actor} target={Target} role={Role} invite={Invite} kind={Kind} coOwner={Was}->{Now}",
roomId, me, playerId, requested.Value, isInvite, kind, wasCoOwner, isCoOwnerNow);
// The invitee's notifications are how the role reaches them: the
// 2023 client renders message Types 60/61/62 (factory
// OCGKOJMFLAI.ALEHDHAEFOK, enum KJECOLODAFA.MEPCALGGMJC) and reads
// only RoomId from them. Self-service changes (accept / reject /
// leave) notify nobody.
if (playerId != me)
{
existing.Accepted = false;
if (!hadCoOwnerInvite && hasCoOwnerInviteNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerInvited, me, roomId: roomId);
if (!wasCoOwner && isCoOwnerNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerAdded, me, roomId: roomId);
if (wasCoOwner && !isCoOwnerNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerRemoved, me, roomId: roomId);
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
await PushRoomUpdateAsync(roomId, details, playerId, me);
return Ok(details);
}
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
/// <summary>Once an invite is answered (or withdrawn) its Type 62 message
/// must stop offering Accept/Reject in the inbox.</summary>
private async Task MarkCoOwnerInviteMessagesReadAsync(long playerId, long roomId)
{
if (room.CreatorPlayerId == playerId)
return BadRequest(new { error = "cannot_change_creator_role" });
var rows = await db.RoomRoles
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
var stale = await db.Messages
.Where(m => m.RecipientPlayerId == playerId
&& m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
&& m.ReadAt == null)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
if (stale.Count == 0) return;
foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
}
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId)
/// <summary>Push the fresh room details as <c>RoomUpdate</c> (handler
/// IBEOONPEELF.txt:507, Action&lt;FGCPNAACHIK&gt;) to everyone currently
/// in the room plus the players whose view changed, so the role list,
/// the role-gated buttons and the local player's own role refresh
/// without leaving the room.</summary>
private async Task PushRoomUpdateAsync(long roomId, object details, params long[] alsoNotify)
{
if (room.CreatorPlayerId == playerId) return true;
return await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id
&& r.PlayerId == playerId
&& r.Accepted
&& r.Role == 0);
var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
.SelectMany(i => i.PlayerIds)
.Concat(alsoNotify)
.Where(pid => pid > 0)
.Distinct()
.ToList();
foreach (var pid in recipients)
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
}
private async Task<int?> ReadClientRoomRoleAsync()
@@ -4486,6 +4674,8 @@ public class RoomsController(
var form = await Request.ReadFormAsync();
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
if (TryParseRoleName(form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault(), out parsed))
return parsed;
}
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
@@ -4495,10 +4685,20 @@ public class RoomsController(
{
try
{
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (body?.Role is int bodyRole) return bodyRole;
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("role", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out parsed))
return parsed;
if (prop.Value.ValueKind == JsonValueKind.String
&& (int.TryParse(prop.Value.GetString(), out parsed)
|| TryParseRoleName(prop.Value.GetString(), out parsed)))
return parsed;
}
}
}
catch (JsonException)
{
@@ -4509,64 +4709,34 @@ public class RoomsController(
return null;
}
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
/// <summary>OMMBGJMJJPN member names, for callers that box the enum
/// itself (Enum.ToString) instead of an Int32.</summary>
internal static bool TryParseRoleName(string? raw, out int role)
{
var rows = await db.RoomRoles
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
}
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
{
roomRole = clientRole switch
role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
{
30 => 0, // CoOwner
20 => 1, // Moderator
10 => 2, // Host
"none" => RoomRoleRules.WireNone,
"banned" => RoomRoleRules.WireBanned,
"host" => RoomRoleRules.WireHost,
"moderator" => RoomRoleRules.WireModerator,
"coowner" => RoomRoleRules.WireCoOwner,
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
"creator" => RoomRoleRules.WireCreator,
_ => -1,
};
return roomRole >= 0;
return role >= 0;
}
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
=> BuildRoomAccountRoleWire(
role.PlayerId,
ToClientRoomRole(role.Role),
role.Accepted,
role.GrantedByPlayerId);
private static object BuildRoomAccountRoleWire(
long accountId,
int role,
bool accepted = true,
long? lastChangedByAccountId = null)
=> new
{
AccountId = ToClientAccountId(accountId),
Role = accepted ? role : 0,
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
InvitedRole = accepted ? 0 : role,
};
private static int ToClientRoomRole(int roomRole) => roomRole switch
/// <summary>Creator or accepted co-owner (see
/// <see cref="RoomRoleRules.CanEditRoom"/>).</summary>
private async Task<bool> CanEditRoomAsync(RoomEntity room, long playerId)
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
private static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
private static int? ToClientAccountId(long? accountId)
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
if (room.CreatorPlayerId == playerId) return true;
var rows = await db.RoomRoles
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
.ToListAsync();
return RoomRoleRules.CanEditRoom(room, rows, playerId);
}
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
{
@@ -501,6 +501,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/description")]
[HttpPut("roomserver/rooms/{roomId:long}/description")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareDescription(long roomId)
{
// RawValue, not the whitespace-skipping reader: clearing the
@@ -520,6 +521,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/image")]
[HttpPut("roomserver/rooms/{roomId:long}/image")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareImage(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -541,6 +543,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareTags(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -567,6 +570,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAccessibility(long roomId)
{
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
@@ -899,6 +903,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareCloning(long roomId)
{
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
@@ -918,6 +923,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAutomute(long roomId)
{
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
@@ -939,6 +945,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareRestrictions(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -965,6 +972,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareWarning(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -983,6 +991,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareCreator(long roomId)
{
var creatorId = await ReadBareLongAsync(
@@ -1001,6 +1010,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAllowNewUsers(long roomId)
{
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
@@ -1015,6 +1025,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareMinLevel(long roomId)
{
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
@@ -1029,6 +1040,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
{
var value = await ReadBareIntAsync(
@@ -1054,6 +1066,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareLoadScreen(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1085,6 +1098,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/loadscreen")]
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareLoadScreenRemove(long roomId)
{
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
@@ -1121,6 +1135,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1137,6 +1152,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_images")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1167,6 +1183,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1197,6 +1214,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1275,6 +1293,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
@@ -1305,6 +1324,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareModify(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1432,6 +1452,13 @@ public class RoomsModerationController(
// it editable. Safe for admin: the purge gate and "RR Original"
// badge both key on CreatorPlayerId == SystemAccountId, so a
// user-owned clone is still shown as Custom and stays purgeable.
//
// The WIRE IsRRO is a different thing: it is false for every
// player-owned room (RoomService.WireIsRRO). The 2023 client's
// room-load pipeline never reads IsRRO (the baked scene comes
// from the sub-room's UnitySceneId, the blob step from DataBlob),
// while its owner UI refuses publish and every room setting on
// an RRO, which is what kept cloned rooms from being published.
IsAGRoom = source.IsAGRoom,
IsDormRoom = false,
CloningAllowed = source.CloningAllowed,
@@ -1509,6 +1536,18 @@ public class RoomsModerationController(
CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = DateTime.UtcNow,
}).ToList();
// A template with no scene rows still has to give the new room a
// real entry sub-room (named "Home", from the clone's own location,
// blob and capacity). Without it the details served a synthesised
// placeholder and Room Settings > Subrooms showed an empty row.
if (clonedScenes.Count == 0)
{
var home = Services.RoomService.NewHomeScene(clone);
home.Id = ++nextSceneId;
clonedScenes.Add(home);
}
foreach (var scene in clonedScenes.Where(s => string.IsNullOrWhiteSpace(s.Name)))
scene.Name = Services.RoomService.DefaultSubRoomName(scene.OrderIndex);
if (clonedScenes.Count > 0)
{
db.RoomScenes.AddRange(clonedScenes);
@@ -1580,8 +1619,11 @@ public class RoomsModerationController(
// The scene was created as a shallow copy, so its DataBlobName is
// currently the SOURCE's. Either a copy replaces it or it gets
// cleared — it must never survive as-is.
// A synthesised Home scene (template had no rows) has no source
// scene; it starts from the room-level blob it was built with.
var sourceBlob = i < sourceScenes.Count ? sourceScenes[i].DataBlobName : target.DataBlobName;
var copied = await CopyBlobForCloneAsync(
sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId);
sourceBlob, clone.Id, target.OrderIndex, callerId);
target.DataBlobName = copied ?? string.Empty;
target.DataModifiedAt = DateTime.UtcNow;
@@ -1892,7 +1934,13 @@ public class RoomsModerationController(
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
/// reader's dispose-walk NRE'd on the missing nested lists and every
/// mutation reported failure even though it had persisted.</summary>
/// mutation reported failure even though it had persisted.
///
/// <para>That helper is <c>NLDBPDCNNCF.BDBHCEGKGDC&lt;TResult&gt;</c>, the
/// <c>{success, value, error}</c> envelope reader, so the details are the
/// envelope's <c>value</c>: every caller carries
/// <see cref="Controllers.RecNetEnvelopeAttribute"/>. A bare details body
/// fails with "Exception of type 'CEMNLBKJABA' was thrown."</para></summary>
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
{
var scenes = await db.RoomScenes
@@ -156,9 +156,19 @@ public class StorefrontsBuyController(
await db.SaveChangesAsync();
// Push the buyer's balance update so their wallet UI refreshes.
// StorefrontBalancePurchase "62" is PurchaseBalanceModificationDTO
// {BalanceAddType, Delta, Balance, Platform, CurrencyType}; Balance
// is the absolute new balance, Delta only feeds the client's log.
await notifications.NotifyAsync(pid,
PushNotificationId.StorefrontBalancePurchase,
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
new
{
BalanceAddType = 0,
Delta = (int)-item.Price,
Balance = newBalance,
Platform = 0,
req.CurrencyType,
});
if (isGift)
{
@@ -166,24 +176,13 @@ public class StorefrontsBuyController(
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
// and pop the "you got a gift" toast; the gift box itself
// appears next time the recipient opens their inventory.
// GiftPackageReceived "30" carries the client's full gift package
// (FHMABOHAEED) — the same object GET api/avatar/v2/gifts lists.
// A hand-picked subset left out PlatformsToSpawnOn (-1 = every
// platform), so the box could count as spawning nowhere.
await notifications.NotifyAsync(recipient,
PushNotificationId.GiftPackageReceived,
new
{
Id = gift.Id,
Platform = -1,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = gift.GiftContext,
FromPlayerId = gift.FromPlayerId,
AvatarItemType = gift.AvatarItemType,
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
EquipmentPrefabName = gift.EquipmentPrefabName,
EquipmentModificationGuid = gift.EquipmentModificationGuid,
Message = gift.Message ?? string.Empty,
});
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
}
// For gift purchases the buyer doesn't get a box — their flow
@@ -154,7 +154,7 @@ public class AccountsController(
playerId, username, summary);
}
await notifications.KickPlayerAsync(playerId, "This account has been deleted.");
await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut);
presence.Clear(playerId);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
@@ -29,6 +29,7 @@ namespace DorkNet.Server.Controllers.Admin;
public class AdminController(
DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
LevelService level,
CommunityBoardService communityBoard,
RoomBlobNormalizerService roomBlobNormalizer,
@@ -160,7 +161,9 @@ public class AdminController(
await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync();
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned.");
// A ban must sign the player out (a kick only sends them to the dorm);
// the next login is refused by the ban check.
await notifications.LogoutAsync(id, NotificationService.LogoutReason.Banned);
playerPresence.Clear(id);
return Ok(new { player.Id, player.BannedUntil });
@@ -243,7 +246,7 @@ public class AdminController(
await db.SaveChangesAsync();
await tx.CommitAsync();
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
playerPresence.Clear(id);
return Ok(new
@@ -1570,10 +1573,13 @@ public class AdminController(
return Ok(new { ok = true });
}
/// <summary>Push fresh RoomDetails after role/owner mutations. The
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts,
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom
/// stores a replacement.</summary>
/// <summary>Push fresh room details after role/owner mutations, to everyone
/// in the room plus the player whose role changed. The client caches the
/// room's Roles and role-gated UI reads that cache. The 2023 "RoomUpdate"
/// handler (IBEOONPEELF.txt:507) reads the FULL room (FGCPNAACHIK) — the
/// shape GET roomserver/rooms/{id} returns. The 2020 {Room, Scenes}
/// wrapper parsed as RoomId 0 with no roles, so role changes never
/// reached anyone until they re-entered the room.</summary>
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -1587,7 +1593,7 @@ public class AdminController(
.ToListAsync();
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
.BuildRoomDetails(room, scenes, roles: roles);
.BuildRoomServerDetails(room, scenes, roles: roles);
var recipients = onlinePresence.OnlinePlayerIds()
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
@@ -1739,7 +1745,7 @@ public class AdminController(
{
var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
await notifications.KickPlayerAsync(pid, reason);
await notifications.KickPlayerAsync(pid, reason, gameSessionId: instanceId);
playerPresence.Clear(pid);
kicked.Add(pid);
}
@@ -2396,7 +2402,9 @@ public class AdminController(
var reason = string.IsNullOrWhiteSpace(body?.Reason)
? "Kicked by an admin."
: body!.Reason!.Trim();
await notifications.KickPlayerAsync(id, reason);
// The room instance lets the client announce the kick to that room.
await notifications.KickPlayerAsync(id, reason,
gameSessionId: playerPresence.GetRoom(id)?.RoomInstanceId ?? 0);
playerPresence.Clear(id);
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync();
@@ -3328,10 +3336,11 @@ public class AdminController(
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
await LogAsync("set_password", "player", id, "");
await db.SaveChangesAsync();
// Push a logout so any active sessions on the old password get
// dropped — the watch re-authenticates with the new password.
await notifications.NotifyAsync(id, PushNotificationId.Logout,
new { Reason = "AdminPasswordReset" });
// Log out any session still on the old password; the client has to
// sign in again with the new one. The reason must be the numeric
// enum: the string "AdminPasswordReset" failed the client's parse and
// the push was dropped.
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
return Ok(new { id });
}
@@ -3500,22 +3509,26 @@ public class AdminController(
playerPresence.SetRoom(id, roomInstance);
// Two pushes back-to-back: GameSession update changes the
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom
// refreshes the watch's RoomDetails cache so the room-info
// panel doesn't show stale data after the warp.
// The move itself comes from the presence set above: the client's
// next heartbeat sees it is out of sync and joins that instance.
// RoomInstanceUpdate only refreshes the client's cached copy of the
// instance, and RoomUpdate its cached room details, which must be
// the full room (FGCPNAACHIK) the roomserver GET returns.
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
var forceJoinScenes = await db.RoomScenes
.Where(sc => sc.RoomId == room.Id)
.OrderBy(sc => sc.OrderIndex)
.ToListAsync();
// With every role grant: the client replaces its cached room with
// this, so a payload without them would drop everyone's roles.
var forceJoinRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateRoom,
new
{
Room = new { Id = room.Id, Name = room.Name },
RoomId = room.Id,
SubRoomId = subRoomId,
Reason = "AdminForceJoin",
});
DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(
room, forceJoinScenes, roles: forceJoinRoles));
await LogAsync("force_join", "player", id,
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
@@ -3532,20 +3545,46 @@ public class AdminController(
// ── System ───────────────────────────────────────────────────────────
public sealed record SetMotdRequest(string Message);
public sealed record SetMotdRequest(string? Message, int? StartsInMinutes);
/// <summary>Push a server-maintenance broadcast to every connected
/// player. The MOTD config value is changed via appsettings.json
/// (server restart required); this is for ad-hoc announcements.</summary>
/// <summary>Broadcast to every connected player.
/// <list type="bullet">
/// <item><c>Message</c>: a coach message (MessageReceived, Type 100, from
/// the system account) in each online player's notifications —
/// the client's channel for free text from the server.</item>
/// <item><c>StartsInMinutes</c>: the client's own maintenance countdown
/// (ServerMaintenance "25", which reads only
/// <c>StartsInMinutes</c>; 0 cancels a running countdown).</item>
/// </list>
/// ServerMaintenance used to carry the text as <c>{Message}</c>, which the
/// client never reads: the text was never shown, and StartsInMinutes 0
/// cancelled any countdown instead.</summary>
[HttpPost("broadcast")]
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
{
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message");
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
new { Message = body.Message });
await LogAsync("broadcast", "system", 0, body.Message);
var message = body.Message?.Trim() ?? string.Empty;
if (message.Length == 0 && body.StartsInMinutes is null)
return BadRequest("empty message");
if (body.StartsInMinutes is < 0)
return BadRequest("StartsInMinutes must be 0 or more");
var recipients = onlinePresence.OnlinePlayerIds().ToList();
if (message.Length > 0)
{
foreach (var pid in recipients)
await systemNotifications.SendAsync(pid,
SystemNotificationService.MessageType.CoachMessage,
fromPlayerId: PlayerService.SystemAccountId,
data: message);
}
if (body.StartsInMinutes is int minutes)
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
new { StartsInMinutes = minutes });
await LogAsync("broadcast", "system", 0,
body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message);
await db.SaveChangesAsync();
return Ok();
return Ok(new { recipients = recipients.Count });
}
// ── Store catalog management ─────────────────────────────────────────
@@ -460,6 +460,20 @@ public class ChatController(
var removed = await db.ChatThreadMembers
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
.ExecuteDeleteAsync();
if (removed > 0)
{
// "PlayerLeftChat" is an Action<ChatMessage> (DLDKCILCKNA.txt:721);
// the handler only reads ChatThreadId and SenderPlayerId (the
// player who left) and drops them from that thread's members.
// Sent to the remaining members, whose member lists otherwise
// stayed stale, and to the leaver's own other sessions.
var thread = await EnsureThreadRowAsync(chatThreadId);
var left = new ChatMessageEntity { ThreadKey = chatThreadId, SenderPlayerId = Me, Body = string.Empty };
var recipients = await ThreadRecipientsAsync(chatThreadId);
recipients.Add(Me);
foreach (var rid in recipients.Distinct())
await notifications.NotifyTypedAsync(rid, "PlayerLeftChat", ToWireMessage(left, thread.Id));
}
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
}
@@ -39,6 +39,7 @@ public class GoToController(
RoomService rooms,
DorkNet.Server.Data.DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
JoinTimeoutService joinTimeouts,
ServerSettingsService serverSettings,
PresenceBroadcastService presenceFanout,
@@ -316,6 +317,10 @@ public class GoToController(
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
{
// The client sends the wire SubRoomId it read from the room details
// (OrderIndex + 1, see RoomSceneIds); everything below works in
// OrderIndex.
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId);
var room = await rooms.GetByIdAsync(roomId);
if (room is null)
{
@@ -877,11 +882,15 @@ public class GoToController(
logger.LogWarning(
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
callerId, playerId, theirRoom.RoomInstanceId);
// Notify the OWNER that someone tried to follow them in.
// Helps surface the rejection so the owner knows there's
// an interested party — they can choose to send an invite.
await notifications.PrivateInstanceJoinRejected(
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0);
// Tell the OWNER someone tried to follow them in, as the
// client's own "wants to join" message (RequestGameInvite,
// from the caller), so they can send an invite. At most once
// per requester, owner and instance per cooldown, so retrying
// doesn't fill the owner's inbox.
if (callerId is long requester
&& JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId))
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 26, // RoomInstanceIsPrivate
@@ -1547,9 +1556,25 @@ public class RoomInstanceDto
[JsonPropertyName("roomId")]
public long RoomId { get; set; }
[JsonPropertyName("subRoomId")]
/// <summary>The sub-room's <see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>
/// (0 = the room's first sub-room). Server code reads and writes this;
/// the wire carries <see cref="WireSubRoomId"/>.</summary>
[JsonIgnore]
public long SubRoomId { get; set; }
/// <summary>The 2023 client's SubRoomId: OrderIndex + 1 (see
/// <see cref="Services.RoomSceneIds"/>). It must equal the
/// <c>SubRooms[].SubRoomId</c> the room details advertise — the client
/// compares the two (<c>SubroomModel.get_SubroomIsLocal</c>) and looks the
/// current sub-room up by it. Presence round-trips through this property
/// too, so stored JSON stays consistent.</summary>
[JsonPropertyName("subRoomId")]
public long WireSubRoomId
{
get => Services.RoomSceneIds.ToWire(SubRoomId);
set => SubRoomId = Services.RoomSceneIds.ToOrderIndex(value);
}
// STRING RoomSceneLocationId GUID — foreign-keys to
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
@@ -1668,3 +1693,35 @@ public class RoomInstanceDto
};
}
}
/// <summary>Rate limit for "wants to join" messages: one per (requester,
/// owner, room instance) per <see cref="Window"/>. Later retries and other
/// instances still go through. Per replica, which is enough to stop a
/// retry loop from flooding an inbox.</summary>
internal static class JoinRequestCooldown
{
public static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new();
/// <summary>True when no request for this key was sent within the window
/// (and records this one); false while it is cooling down.</summary>
public static bool TryStart(long requester, long owner, long roomInstanceId)
{
var now = DateTime.UtcNow;
if (Last.Count > 10_000)
foreach (var (key, at) in Last)
if (now - at > Window) Last.TryRemove(key, out _);
var k = (requester, owner, roomInstanceId);
while (true)
{
if (!Last.TryGetValue(k, out var prev))
{
if (Last.TryAdd(k, now)) return true;
continue;
}
if (now - prev < Window) return false;
if (Last.TryUpdate(k, now, prev)) return true;
}
}
}
@@ -140,66 +140,48 @@ public class MatchController(
});
}
/// <summary>GET <c>photon_access_token</c> — read by the room-permissions
/// runtime as <c>ALIIIAGAPND {RoomInstanceId, PhotonAccessToken,
/// Permissions}</c> (NLDBPDCNNCF.CEOPCANHPPH, verb 0, issued from the
/// rooms client, hence the <c>roomserver/</c> twin).
/// <c>Permissions</c> is the role-permission matrix of the sub-room the
/// player is in: the defaults plus whatever the room's editors saved with
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>
/// (<see cref="RoomPermissionMatrix"/>). It must not be empty: with no
/// Permissions the client falls into its persisted-role migration path,
/// which NREs during first spawn.</summary>
[HttpGet("/photon_access_token")]
public IActionResult PhotonAccessToken()
[HttpGet("/roomserver/photon_access_token")]
public async Task<IActionResult> PhotonAccessToken()
{
var pid = this.RequireCurrentPlayerId();
var appId = config["Photon:AppId"] ?? string.Empty;
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var currentRoom = presence.GetRoom(pid);
var permissions = BuildPhotonPermissionSeed();
var permissions = currentRoom is null
? RoomPermissionMatrix.Merge(Array.Empty<RoomRolePermissionEntity>())
: await RoomPermissionMatrix.LoadAsync(db, currentRoom.RoomId, currentRoom.SubRoomId);
logger.LogInformation(
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length);
return Ok(new Dictionary<string, object>
{
// 2023 deserializes this endpoint as ALIIIAGAPND before room
// permissions initialization. If Permissions is missing/empty,
// OMJKHJLFOCO falls into the persisted-role migration path that
// currently NREs during first spawn.
["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L,
["PhotonAccessToken"] = string.Empty,
["Permissions"] = permissions,
"[photon-token] player={PlayerId} room={RoomId} sub={SubRoomId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
pid, currentRoom?.RoomId ?? 0L, currentRoom?.SubRoomId ?? 0L, currentRoom?.RoomInstanceId ?? 0L, permissions.Count);
// Existing custom-auth/app-id shape kept for Photon auth callers.
["ResultCode"] = 1,
["Message"] = string.Empty,
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
["PhotonAppId"] = appId,
["PhotonVoiceAppId"] = voiceAppId,
["AppId"] = appId,
["VoiceAppId"] = voiceAppId,
["CloudRegion"] = region,
["Region"] = region,
["Token"] = string.Empty,
});
var body = RoomPermissionMatrix.AccessTokenBody(currentRoom?.RoomInstanceId ?? 0L, permissions);
// Existing custom-auth/app-id shape kept for Photon auth callers; the
// ALIIIAGAPND reader ignores keys it does not register.
body["ResultCode"] = 1;
body["Message"] = string.Empty;
body["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture);
body["PhotonAppId"] = appId;
body["PhotonVoiceAppId"] = voiceAppId;
body["AppId"] = appId;
body["VoiceAppId"] = voiceAppId;
body["CloudRegion"] = region;
body["Region"] = region;
body["Token"] = string.Empty;
return Ok(body);
}
private static object[] BuildPhotonPermissionSeed()
=>
[
BuildPhotonPermission(13, 30), // Can Use Maker Pen
BuildPhotonPermission(20, 30), // Can Spawn Inventions
BuildPhotonPermission(21, 30), // Can Spawn Consumables
BuildPhotonPermission(22, 30), // Can Use Room Reset Button
BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
BuildPhotonPermission(13, 255), // Creator alias
BuildPhotonPermission(20, 255),
BuildPhotonPermission(21, 255),
BuildPhotonPermission(22, 255),
BuildPhotonPermission(23, 255),
];
private static object BuildPhotonPermission(int permission, int role) => new
{
Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
Role = role,
Override = true,
Type = 0,
Value = "true",
};
[HttpPost("/matchmake/dorm")]
public async Task<IActionResult> MatchmakeDorm()
{
@@ -490,97 +472,125 @@ public class MatchController(
return Ok(session);
}
/// <summary>GET <c>match.*/room/{id}/instances</c> — the
/// "RoomInstanceBrowser" the watch shows when you tap a room tile.
/// Returns the currently-active instances so the player can see
/// which one their friends are in (and join it) instead of being
/// matchmade into a fresh empty room. Verified URL at
/// Matchmaking.txt:6286; response is a
/// <c>List&lt;SimpleRoomInstance&gt;</c> per
/// <c>SimpleRoomInstance.cs</c>: RoomInstanceId/RoomId/SubRoomId,
/// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
/// HasModPresent, HashedInstanceId.
/// <summary>GET <c>match.*/room/{id}/instances</c> — the room instance
/// browser.
///
/// We derive instances from PlayerPresenceService's per-player
/// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId
/// is one instance, with the players currently presenced there.
/// PrivateInstances are surfaced as well so the inviter and
/// invitee see the same room in the browser.</summary>
/// <para><b>Client contract.</b> <c>RecNet.Matchmaking.KAIPGDGACGG(long)</c>
/// (RecNet.Runtime/RecNet/Matchmaking.txt:13966, route at :14237, verb 0 =
/// GET on the matchmaking host) returns <c>List&lt;PNDCMIMEJLD&gt;</c>, a bare
/// array. The reader (JPNMBICAMEE.txt) takes exactly RoomInstanceId,
/// RoomId, SubRoomId, IsFull, CreatedAt and PlayerIds; the post-parse hook
/// (PNDCMIMEJLD.FKDDCNLJOLF) derives PlayerCount from PlayerIds.Count,
/// SubroomName from the cached room details' SubRooms by SubRoomId, and
/// HasModPresent from the room's Roles. The screen
/// (RoomInstanceBrowserScreen / RoomInstancePagedGrid) shows every row it
/// is given, current instance first then oldest CreatedAt first, and
/// renders the age as UtcNow - CreatedAt, so CreatedAt must be UTC.</para>
///
/// <para><b>What is listed.</b> Every live instance of the room: each
/// distinct Photon room in which at least one player is presenced with a
/// live activity marker (<see cref="PlayerPresenceService.EnumerateActiveInstances"/>).
/// Private instances are included only for callers allowed into them —
/// the room creator, accepted role holders, the instance owner, its
/// invitees, and players already inside — so another room's invite-only
/// match never leaks. Empty instances are not listed: there is nothing to
/// join and they are what made the old list stale.</para>
///
/// <para><b>Fixed defects.</b> The Redis presence path returned nothing at
/// all, so the browser was empty on every multi-replica deployment;
/// private instances (every user room is born private and has one
/// instance per sub-room) were dropped from presence and re-added only
/// for their owner with an empty PlayerIds, so they showed 0 players or
/// nothing; expired or crashed presences still counted; IsFull was always
/// false; and SubRoomId used the OrderIndex, which does not match the
/// room details' wire ids.</para></summary>
[HttpGet("/room/{roomId:long}/instances")]
[AllowAnonymous]
public async Task<IActionResult> RoomInstances(long roomId)
{
var presenced = presence.EnumerateActiveInstances(roomId).ToList();
// Only surface the caller's OWN private instances — we never
// leak someone else's PhotonRoomId into the browser. The Photon
// join check still requires CanJoinAsync, but redacting at the
// list level matches the existing RoomInstanceDto.Redact policy.
var pid = this.CurrentPlayerId() ?? 0;
var privates = pid == 0
? new List<Data.Entities.PrivateInstanceEntity>()
: await db.PrivateInstances
.Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
.ToListAsync();
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return Ok(Array.Empty<object>());
// Merge: presence rows are authoritative (player currently
// there); add any owned private instance the inviter created
// but nobody's joined yet so the inviter can still see it.
var byKey = new Dictionary<string, SimpleInstance>(StringComparer.Ordinal);
foreach (var (room, playerIds) in presenced)
var live = presence.EnumerateActiveInstances(roomId, includePrivate: true).ToList();
var privateIds = live.Where(l => l.Room.IsPrivate).Select(l => l.Room.RoomInstanceId).Distinct().ToList();
var privateRows = privateIds.Count == 0
? new Dictionary<long, PrivateInstanceEntity>()
: await db.PrivateInstances.AsNoTracking()
.Where(p => privateIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id);
var callerHasRole = pid != 0 && (room.CreatorPlayerId == pid
|| await db.RoomRoles.AsNoTracking().AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == pid && r.Accepted));
var invitedTo = pid == 0 || privateIds.Count == 0
? new HashSet<long>()
: (await db.PrivateInstanceInvitees.AsNoTracking()
.Where(i => i.PlayerId == pid && privateIds.Contains(i.PrivateInstanceId))
.Select(i => i.PrivateInstanceId)
.ToListAsync()).ToHashSet();
var rows = new List<RoomInstanceRow>();
foreach (var (inst, playerIds) in live)
{
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
byKey[room.PhotonRoomId] = new SimpleInstance(
RoomInstanceId: room.RoomInstanceId,
RoomId: room.RoomId,
SubRoomId: room.SubRoomId,
IsFull: false,
CreatedAt: DateTime.UtcNow,
PlayerIds: playerIds.Select(p => (int)p).ToList(),
SubroomName: room.Name ?? string.Empty,
PlayerCount: playerIds.Count,
HasModPresent: false,
HashedInstanceId: room.PhotonRoomId);
}
foreach (var p in privates)
{
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
byKey[p.PhotonRoomId] = new SimpleInstance(
RoomInstanceId: p.Id,
RoomId: p.RoomId,
SubRoomId: p.SubRoomId,
IsFull: false,
CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
PlayerIds: new List<int>(),
SubroomName: p.Name,
PlayerCount: 0,
HasModPresent: false,
HashedInstanceId: p.PhotonRoomId);
privateRows.TryGetValue(inst.RoomInstanceId, out var privateRow);
if (inst.IsPrivate && !CanSeePrivateInstance(
pid, callerHasRole, playerIds, privateRow, invitedTo.Contains(inst.RoomInstanceId)))
continue;
var capacity = inst.MaxCapacity > 0 ? inst.MaxCapacity : privateRow?.MaxCapacity ?? 0;
rows.Add(new RoomInstanceRow(
RoomInstanceId: inst.RoomInstanceId,
RoomId: inst.RoomId,
SubRoomId: inst.SubRoomId,
IsFull: capacity > 0 && playerIds.Count >= capacity,
CreatedAt: privateRow is not null
? DateTime.SpecifyKind(privateRow.CreatedAt, DateTimeKind.Utc)
: DateTime.UtcNow,
PlayerIds: playerIds.Distinct().OrderBy(p => p).ToList()));
}
// Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE
// keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt
// lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull,
// createdAt, playerIds}). SubroomName / PlayerCount /
// HasModPresent / HashedInstanceId are populated locally on the
// watch (PlayerIds.Count, local hash, etc.) — not read from
// JSON, so we don't send them. Earlier PascalCase response made
// every field default to 0/null on the watch, breaking the
// browser.
return Ok(byKey.Values.Select(i => new
{
roomInstanceId = i.RoomInstanceId,
roomId = i.RoomId,
subRoomId = i.SubRoomId,
isFull = i.IsFull,
createdAt = i.CreatedAt,
playerIds = i.PlayerIds.ToArray(),
}));
logger.LogInformation(
"[room-instances] room={Room} caller={Caller} live={Live} listed={Listed}",
roomId, pid, live.Count, rows.Count);
return Ok(rows.OrderBy(r => r.CreatedAt).Select(ToWire));
}
private sealed record SimpleInstance(
/// <summary>Who may see (and so be offered a join into) a private
/// instance in the browser.</summary>
public static bool CanSeePrivateInstance(
long callerId,
bool callerHasRoomRole,
IReadOnlyCollection<long> playersInside,
PrivateInstanceEntity? privateRow,
bool callerInvited)
{
if (callerId == 0) return false;
if (callerHasRoomRole) return true;
if (playersInside.Contains(callerId)) return true;
if (privateRow is not null && privateRow.OwnerPlayerId == callerId) return true;
return callerInvited;
}
internal sealed record RoomInstanceRow(
long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
DateTime CreatedAt, List<int> PlayerIds, string SubroomName,
int PlayerCount, bool HasModPresent, string HashedInstanceId);
DateTime CreatedAt, List<long> PlayerIds);
/// <summary>PNDCMIMEJLD wire row. Account ids are Int32 on this DTO;
/// SubRoomId is the wire id the room details advertise
/// (<see cref="RoomSceneIds"/>), which the client uses to name the row.
/// The reader matches PascalCase, camelCase and lowercase keys; camelCase
/// is what this route has always sent.</summary>
internal static object ToWire(RoomInstanceRow r) => new
{
roomInstanceId = r.RoomInstanceId,
roomId = r.RoomId,
subRoomId = RoomSceneIds.ToWire(r.SubRoomId),
isFull = r.IsFull,
createdAt = r.CreatedAt,
playerIds = r.PlayerIds.Select(p => (int)p).ToArray(),
};
/// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
@@ -69,17 +69,15 @@ public class MatchPlayerController(
if (replaced)
{
log.LogWarning(
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out",
logScope, playerId, takeOverExclusiveSession);
// The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's
// boot sequence — verified in the live trace), so the
// ModerationKick push hits only the OLDER session's
// existing connections. They'll show the "you've been
// kicked" dialog and gracefully disconnect via the
// watch's LocalModerationKickSelf path.
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
// (negotiate fires AFTER /player/login on the boot
// sequence — verified in the live trace), so the Logout
// push (Reason SessionTakeover) reaches only the OLDER
// session, which shows the client's own "signed in
// elsewhere" message and returns to the boot scene.
await notifications.LogoutStaleSession(playerId);
}
}
return Ok(Array.Empty<object>());
@@ -0,0 +1,115 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace DorkNet.Server.Controllers;
/// <summary>
/// Wraps an action's reply in the legacy <c>{success, value, error}</c>
/// envelope the 2023 rooms client reads for every room and playlist mutation.
///
/// <para><b>Client contract.</b> All of these calls go through one generic
/// helper, <c>NLDBPDCNNCF.BDBHCEGKGDC&lt;TResult&gt;(string opName, HTTPMethods,
/// string route, …)</c> (RVA 0x35E50A0, recnet-runtime-decomp/NLDBPDCNNCF.cs:2310;
/// called as <c>Call 0x1835E50A0</c> 47 times in
/// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt). It is the same helper, with the
/// same <c>BDBHCEGKGDC&lt;FGCPNAACHIK&gt;</c> instantiation token
/// <c>[0x1884C1948]</c>, that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
/// clone is proven in game to need <c>{success, value, error}</c>
/// (docs/recroom-2023-room-save.md). A bare room-details body has no
/// <c>success</c>, so the helper rejects it with no message and the player sees
/// <c>Exception of type 'CEMNLBKJABA' was thrown.</c> even though the server
/// applied the change. The routes are: rooms/{0}/{accessibility,
/// allow_new_users, automute, cloning, comments, creator, description, image,
/// loadscreen, max_player_calculation_mode, min_level, modify, name,
/// promo_external, promo_images, restrictions, tags, voice_chat_encryption,
/// warning}, rooms/{0}/roles/{1}[/invite], rooms/{0}/subrooms[/{1}[/accessibility,
/// clone, maxplayers, modify, move, name, permissions, publish_save]],
/// playlists and playlists/{0}/{accessibility, description, image, levelvoting,
/// name, restrictions, rooms/{1}, tags, warning}.</para>
///
/// <para>A 2xx object result becomes <c>{success:true, value, error:""}</c>.
/// Anything else becomes HTTP 200 <c>{success:false, value:null, error}</c>
/// with a message the client can show, because a non-2xx reply also surfaces
/// only as the message-less CEMNLBKJABA.</para>
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class RecNetEnvelopeAttribute : ResultFilterAttribute
{
public override void OnResultExecuting(ResultExecutingContext context)
{
context.Result = Wrap(context.Result);
base.OnResultExecuting(context);
}
public static IActionResult Wrap(IActionResult result)
{
switch (result)
{
case ObjectResult obj when obj.StatusCode is null or (>= 200 and < 300):
return IsEnvelope(obj.Value)
? result
: Success(obj.Value);
case ObjectResult obj:
return Failure(ErrorText(obj.Value) ?? DefaultError(obj.StatusCode ?? 500));
case ForbidResult or ChallengeResult or UnauthorizedResult:
return Failure(DefaultError(403));
case StatusCodeResult code when code.StatusCode is >= 200 and < 300:
return Success(null);
case StatusCodeResult code:
return Failure(DefaultError(code.StatusCode));
case EmptyResult:
return Success(null);
default:
return result;
}
}
public static OkObjectResult Success(object? value) =>
new(new { success = true, value, error = string.Empty });
public static OkObjectResult Failure(string error) =>
new(new { success = false, value = (object?)null, error });
private static string DefaultError(int status) => status switch
{
400 => "That request was not valid.",
401 or 403 => "You don't have permission to do that.",
404 => "That room doesn't exist.",
409 => "That name is already taken.",
_ => "Something went wrong.",
};
/// <summary>An <c>error</c>/<c>Error</c> string on the failure body, if any.</summary>
private static string? ErrorText(object? value)
{
if (value is null) return null;
if (value is string s) return string.IsNullOrWhiteSpace(s) ? null : s;
try
{
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value));
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("error", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind == JsonValueKind.String)
{
var text = prop.Value.GetString();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
}
catch (NotSupportedException)
{
}
return null;
}
private static bool IsEnvelope(object? value)
{
if (value is null) return false;
var props = value.GetType().GetProperties();
bool Has(string name) => props.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
return Has("success") && Has("value") && Has("error");
}
}
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Site;
/// <summary>
/// The client builds CI publishes, for the site's Downloads page and the
/// Quest page's "latest build" install. Files are streamed from the downloads
/// bucket (<see cref="DownloadsService"/>) with Range support, so the browser
/// installer can read an .xapk's zip directory and entries without fetching
/// the whole file. Only files the manifest lists are served.
///
/// The builds contain Rec Room's own game files, so the routes answer only on
/// the site's own host (the apex) and 404 on every other host the server
/// accepts: the game's API/CDN subdomains are typically left open for the
/// client, while the site sits behind an access proxy (e.g. Cloudflare Access).
/// </summary>
[ApiController]
[AllowAnonymous]
[Route("api/site/v1/downloads")]
public class DownloadsController(DownloadsService downloads, DomainConfig domain) : ControllerBase
{
private bool Available => downloads.IsConfigured
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
/// <summary>GET /api/site/v1/downloads — the manifest as published
/// (snake_case keys), or 404 when downloads aren't set up or the request
/// isn't for the site's host.</summary>
[HttpGet]
public async Task<IActionResult> GetManifest(CancellationToken ct)
{
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
var manifest = await downloads.GetManifestAsync(ct);
Response.Headers.CacheControl = "no-cache";
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
.ToJsonString(), "application/json");
}
/// <summary>GET /api/site/v1/downloads/files/{path} — one listed file,
/// streamed; honours Range (206).</summary>
[HttpGet("files/{**path}")]
public async Task GetFile(string path, CancellationToken ct)
{
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
var manifest = await downloads.GetManifestAsync(ct);
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
using var upstream = await downloads.GetFileAsync(path, Request.Headers.Range.ToString(), ct);
Response.StatusCode = (int)upstream.StatusCode;
if (!upstream.IsSuccessStatusCode) return;
var content = upstream.Content.Headers;
if (content.ContentLength is long length) Response.ContentLength = length;
if (content.ContentRange is not null) Response.Headers.ContentRange = content.ContentRange.ToString();
Response.Headers.AcceptRanges = "bytes";
if (upstream.Headers.ETag is not null) Response.Headers.ETag = upstream.Headers.ETag.ToString();
Response.Headers.CacheControl = "private, no-cache";
Response.ContentType = "application/octet-stream";
var name = (string?)entry["name"] ?? Path.GetFileName(path);
Response.Headers.ContentDisposition = new System.Net.Mime.ContentDisposition { FileName = name }.ToString();
await using var body = await upstream.Content.ReadAsStreamAsync(ct);
await body.CopyToAsync(Response.Body, 1 << 16, ct);
}
}
+9
View File
@@ -11,6 +11,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>();
public DbSet<RoomEntity> Rooms => Set<RoomEntity>();
public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>();
public DbSet<RoomRolePermissionEntity> RoomRolePermissions => Set<RoomRolePermissionEntity>();
public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>();
public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>();
public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>();
@@ -134,6 +135,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique();
});
// One cell per (room, sub-room, role, permission): the sub-room
// permissions PUT upserts the cells it was sent.
modelBuilder.Entity<RoomRolePermissionEntity>(e =>
{
e.HasKey(p => p.Id);
e.HasIndex(p => new { p.RoomId, p.SubRoomId, p.Role, p.Permission }).IsUnique();
});
modelBuilder.Entity<ClubAdditionalImageEntity>(e =>
{
e.HasKey(i => i.Id);
@@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
namespace DorkNet.Server.Data.Entities;
/// <summary>
/// One cell of a sub-room's role-permission matrix, as written by
/// <c>PUT rooms/{roomId}/subrooms/{subRoomId}/permissions</c> and served back
/// in the <c>Permissions</c> list of <c>GET photon_access_token</c>.
///
/// The 2023-03-21 client models a cell as <c>BNLPBMJJOMM</c>
/// (formatter <c>RecNet.Runtime/HDOJMEBCAGP.txt</c>, keys
/// <c>Permission</c> / <c>Role</c> / <c>Override</c> / <c>Type</c> /
/// <c>Value</c>): Permission and Value are strings, Role is the room-role
/// enum <c>OMMBGJMJJPN</c> (None 0, Host 10, Moderator 20, CoOwner 30,
/// TemporaryCoOwner 31, Creator 255), Type is <c>FEOJFGECKGH</c>
/// (Bool 0, Int 1, StringList 2) and Override is a bool.
///
/// The editor sends only the cells it changed (the room-permissions runtime
/// builds a one-element <c>BNLPBMJJOMM[]</c> per edit,
/// <c>OMJKHJLFOCO.AFHJLBBFAGP.&lt;LocalRequestModifyRolePermission&gt;g__ChangeAndRefresh</c>),
/// so the write is an upsert per (room, sub-room, role, permission) and never
/// a replace of the whole matrix.
/// </summary>
public class RoomRolePermissionEntity
{
public long Id { get; set; }
public long RoomId { get; set; }
/// <summary>The wire sub-room id from the route (the same value the room
/// details advertise as <c>SubRooms[].SubRoomId</c>).</summary>
public long SubRoomId { get; set; }
/// <summary>Wire room-role value (OMMBGJMJJPN), e.g. 30 = CoOwner, 255 = Creator.</summary>
public int Role { get; set; }
/// <summary>The permission id exactly as the client sent it.</summary>
[MaxLength(128)]
public string Permission { get; set; } = string.Empty;
/// <summary>FEOJFGECKGH: 0 = Bool, 1 = Int, 2 = StringList.</summary>
public int Type { get; set; }
public bool Override { get; set; }
/// <summary>The value exactly as the client sent it (a string on the wire).</summary>
[MaxLength(4096)]
public string Value { get; set; } = string.Empty;
public long UpdatedByPlayerId { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+110 -37
View File
@@ -27,15 +27,16 @@ namespace DorkNet.Server.Hubs;
/// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> which only
/// reflected this replica's connections.
///
/// Hub methods invoked by the client (verified by string-literal scan
/// of metadata at addresses 0x39b2470, 0x39b24c8, 0x39b4ba8):
/// "Subscribe" - generic topic subscribe
/// "Unsubscribe" - generic topic unsubscribe
/// "SubscribeToPlayers" - subscribe to per-player events (used by
/// RecNet.NotificationManager / SubscribeToPlayers)
/// All accept JsonElement so we don't lock ourselves into a parameter
/// schema we haven't verified yet, and return Task so the client's
/// awaited Invoke completes cleanly.
/// Hub methods the 2023-03-21 client sends (fire-and-forget, RecNet.Runtime
/// OMHPBIEHOHN):
/// "SubscribeToPlayers" - {PlayerIds}: players to get presence for
/// "SubscribeTo" - {Channels}: always empty in this build
/// "UnsubscribeFrom" - {Channels}: always empty in this build
/// There is no UnsubscribeFromPlayers; SubscribeToPlayers always carries the
/// complete set. All accept JsonElement and tolerate casing differences.
///
/// The client reconnects at most 3 times per login (its attempt counter is
/// never reset), so the server must not drop healthy connections.
/// </summary>
[Authorize]
public class NotifyHub(
@@ -65,28 +66,25 @@ public class NotifyHub(
// Instead: at the moment the SECOND session's WebSocket
// arrives, take a snapshot of the EXISTING connection ids
// (which still belong to the original session — we haven't
// joined the group yet) and push a targeted ModerationKick
// to those ids. The watch's OnModerationKick handler at
// PlayerReporting.txt:1857 shows the standard moderation
// dialog and routes through LocalModerationKickSelf, the
// same graceful disconnect path the original Rec Room used
// for real moderator kicks.
// joined the group yet) and send those ids a Logout with
// Reason SessionTakeover. The client shows its own "signed in
// elsewhere" message and returns to the boot scene. (A
// ModerationKick only sent the old session to its dorm, still
// signed in.)
//
// Targeting specific connection ids (not the group) means
// the new connection — about to join the group on the next
// line — never receives the kick, even with Redis backplane
// line — never receives it, even with Redis backplane
// routing across replicas.
var existing = presence.GetConnections(id);
if (existing.Count > 0)
{
logger.LogWarning(
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); kicking {OldCount} existing connection(s)",
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); logging out {OldCount} existing connection(s)",
id, Context.ConnectionId, existing.Count);
try
{
await notifications.KickConnectionsAsync(
existing,
"Your account was signed in on another device.");
await notifications.LogoutConnectionsAsync(existing);
}
catch (Exception ex)
{
@@ -156,36 +154,111 @@ public class NotifyHub(
await base.OnDisconnectedAsync(exception);
}
public Task SubscribeToPlayers(JsonElement payload)
/// <summary>SignalR group a connection joins to receive one player's
/// presence. Only presence is sent here — a subscriber must never receive
/// the subject's own pushes (DMs, gifts, …).</summary>
public static string PresenceGroupForPlayer(long playerId) => $"presence:{playerId}";
private const string SubscriptionsKey = "presence-subscriptions";
/// <summary>Most players one connection may subscribe to. The client sends
/// its relationships plus the players around it (dozens); anything far
/// past that is not a real client, and each id costs a group membership
/// on the backplane.</summary>
public const int MaxPresenceSubscriptions = 5000;
/// <summary><c>SubscribeToPlayers(PlayerSubscriptionListDTO {PlayerIds:
/// List&lt;int&gt;})</c> (RecNet.Runtime OMHPBIEHOHN.txt:3430): the full set
/// of players this client wants presence for — its relationships, the
/// players around it, and itself. Sent on every (re)connect and whenever
/// the set changes, always as the complete set, so it REPLACES the
/// connection's subscriptions. Presence for those players is then pushed
/// to <see cref="PresenceGroupForPlayer"/> by
/// <see cref="PresenceBroadcastService"/>, so non-friends the client is
/// looking at (people in the room, an open profile) stay current too.
///
/// The caller's own id is skipped: a presence frame about the local player
/// is applied as a correction and moves them. An unreadable payload
/// leaves the current subscriptions alone rather than clearing them.
/// Groups live per connection on its own replica, are routed cluster-wide
/// by the Redis backplane, and are dropped by SignalR on disconnect.</summary>
public async Task SubscribeToPlayers(JsonElement payload)
{
var me = TryGetPlayerId();
if (!TryReadPlayerIds(payload, out var requested))
{
logger.LogWarning(
"[hub] SubscribeToPlayers unreadable payload player={PlayerId} conn={ConnectionId} payload={Payload}",
me, Context.ConnectionId, RawPayload(payload));
return;
}
if (me is long self) requested.Remove(self);
if (requested.Count > MaxPresenceSubscriptions)
{
logger.LogWarning(
"[hub] SubscribeToPlayers over the {Max} limit player={PlayerId} conn={ConnectionId} count={Count}; kept the current set",
MaxPresenceSubscriptions, me, Context.ConnectionId, requested.Count);
return;
}
var current = Context.Items.TryGetValue(SubscriptionsKey, out var existing) && existing is HashSet<long> set
? set
: new HashSet<long>();
var added = requested.Except(current).ToList();
var removed = current.Except(requested).ToList();
foreach (var id in added)
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
foreach (var id in removed)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
Context.Items[SubscriptionsKey] = requested;
logger.LogInformation(
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} subscribed={Count} added={Added} removed={Removed}",
me, Context.ConnectionId, requested.Count, added.Count, removed.Count);
}
/// <summary><c>SubscribeTo({Channels: List&lt;string&gt;})</c>. The
/// 2023-03-21 client composes it on every connect but its channel set is
/// always empty, so there is nothing to subscribe to; logged only.</summary>
public Task SubscribeTo(JsonElement payload)
{
logger.LogInformation(
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} payload={Payload}",
"[hub] SubscribeTo player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask;
}
public Task UnsubscribeFromPlayers(JsonElement payload)
/// <summary>Counterpart of <see cref="SubscribeTo"/>; logged only.</summary>
public Task UnsubscribeFrom(JsonElement payload)
{
logger.LogInformation(
"[hub] UnsubscribeFromPlayers player={PlayerId} conn={ConnectionId} payload={Payload}",
"[hub] UnsubscribeFrom player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask;
}
public Task Subscribe(JsonElement payload)
/// <summary>Reads <c>{PlayerIds:[…]}</c> (any key casing), or a bare
/// array, as player ids; numbers or numeric strings.</summary>
public static bool TryReadPlayerIds(JsonElement payload, out HashSet<long> ids)
{
logger.LogInformation(
"[hub] Subscribe player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask;
}
public Task Unsubscribe(JsonElement payload)
{
logger.LogInformation(
"[hub] Unsubscribe player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask;
ids = new HashSet<long>();
var list = payload;
if (payload.ValueKind == JsonValueKind.Object)
{
var match = payload.EnumerateObject()
.Where(prop => string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase))
.Select(prop => (JsonElement?)prop.Value)
.FirstOrDefault();
if (match is not JsonElement value) return false;
list = value;
}
if (list.ValueKind != JsonValueKind.Array) return false;
foreach (var item in list.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var n) && n > 0) ids.Add(n);
else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out var p) && p > 0) ids.Add(p);
}
return true;
}
private long? TryGetPlayerId()
@@ -0,0 +1,3718 @@
// <auto-generated />
using System;
using DorkNet.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DorkNet.Server.Migrations
{
[DbContext(typeof(DorkNetDbContext))]
[Migration("20261024201104_RoomRolePermissions")]
partial class RoomRolePermissions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("AdminPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AdminPlayerId");
b.HasIndex("Timestamp");
b.HasIndex("TargetType", "TargetId");
b.ToTable("AdminActions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CustomAvatarItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EquippedItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FaceFeatures")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<string>("HairColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("InventoryJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("OutfitSelectionsV2")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("SavedOutfitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SkinColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("Avatars");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ClientVersion")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ReadAt");
b.HasIndex("ReporterPlayerId");
b.ToTable("BugReports");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long?>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ExpiresAt");
b.HasIndex("PlayerId");
b.ToTable("Cards");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBuiltIn")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WordsJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CharadesWordLists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ModerationState")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey", "SentAt");
b.ToTable("ChatMessages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey")
.IsUnique();
b.ToTable("ChatThreads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<long?>("LastReadMessageId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SnoozeUntil")
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("ThreadKey", "PlayerId")
.IsUnique();
b.ToTable("ChatThreadMembers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CheeredAt")
.HasColumnType("TEXT");
b.Property<long>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPhotoId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TargetPhotoId");
b.HasIndex("TargetPlayerId");
b.HasIndex("TargetRoomId");
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
.IsUnique();
b.ToTable("Cheers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Slot")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "Slot")
.IsUnique();
b.ToTable("ClubAdditionalImages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AuthorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "CreatedAt");
b.ToTable("ClubAnnouncements");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AnnouncementId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("ReadAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("AnnouncementId", "PlayerId")
.IsUnique();
b.ToTable("ClubAnnouncementReads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CategoryTagId")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CategoryTagId");
b.HasIndex("ClubId", "CategoryTagId")
.IsUnique();
b.ToTable("ClubCategoryAssignments");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("ClubCategoryTags");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowJuniors")
.HasColumnType("INTEGER");
b.Property<int>("BanStatus")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("ClubChatEnabled")
.HasColumnType("INTEGER");
b.Property<int>("ClubType")
.HasColumnType("INTEGER");
b.Property<long?>("ClubhouseRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsRRO")
.HasColumnType("INTEGER");
b.Property<int>("Joinability")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Visibility")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Clubs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("ClubId", "PlayerId")
.IsUnique();
b.ToTable("ClubMemberships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ApproveMember")
.HasColumnType("INTEGER");
b.Property<bool>("BanUnban")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<bool>("CreateEvent")
.HasColumnType("INTEGER");
b.Property<bool>("EditDetails")
.HasColumnType("INTEGER");
b.Property<bool>("EditPermissionSettings")
.HasColumnType("INTEGER");
b.Property<int>("MembershipType")
.HasColumnType("INTEGER");
b.Property<bool>("PostAnnouncement")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "MembershipType")
.IsUnique();
b.ToTable("ClubRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SubscribedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId");
b.HasIndex("PlayerId", "ClubId")
.IsUnique();
b.ToTable("ClubSubscriptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AssignedAt")
.HasColumnType("TEXT");
b.Property<string>("CohortKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Variant")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CohortKey")
.IsUnique();
b.ToTable("CohortAssignments");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CommunityBoardRows");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxRedemptions")
.HasColumnType("INTEGER");
b.Property<int>("RedemptionCount")
.HasColumnType("INTEGER");
b.Property<long>("RewardAmount")
.HasColumnType("INTEGER");
b.Property<int>("RewardCurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("RewardItemSlug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Coupons");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CouponId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CouponId", "PlayerId")
.IsUnique();
b.ToTable("CouponRedemptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CurrencyType")
.IsUnique();
b.ToTable("CurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("BaseAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("CheerCount")
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("PurchaseCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("IsFeatured");
b.HasIndex("IsPublic");
b.HasIndex("PublicId")
.IsUnique();
b.ToTable("CustomAvatarItems");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<long>("CustomAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CustomAvatarItemId");
b.HasIndex("PlayerId", "CustomAvatarItemId")
.IsUnique();
b.ToTable("CustomAvatarItemOwnership");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
{
b.Property<long>("PlayerId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("PlayerId");
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<long>("GrantedItemId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("Offer1ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer2ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer3ItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SelectedAt")
.HasColumnType("TEXT");
b.Property<int?>("SelectedGiftDropId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "SelectedAt");
b.ToTable("GameRewardSelections");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActivityLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PlayerCount")
.HasColumnType("INTEGER");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("RoomId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "Region");
b.ToTable("GameSessions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AvatarItemDescOrHairDyeDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("AvatarItemType")
.HasColumnType("INTEGER");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("Consumed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("EquipmentModificationGuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("EquipmentPrefabName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<int>("GiftRarity")
.HasColumnType("INTEGER");
b.Property<bool>("IsGifted")
.HasColumnType("INTEGER");
b.Property<bool>("IsValid")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("PackageMaterial")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PackageVariant")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("SourceStoreItemId")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsCurrentPlatform")
.HasColumnType("INTEGER");
b.Property<int>("Xp")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "Consumed");
b.ToTable("GiftPackages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long?>("CreationRoomId")
.HasColumnType("INTEGER");
b.Property<int>("CreatorPermission")
.HasColumnType("INTEGER");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("CurrentVersionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("FirstPublishedAt")
.HasColumnType("TEXT");
b.Property<int>("GeneralPermission")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAgInvention")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublished")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("NumPlayersHaveUsedInRoom")
.HasColumnType("INTEGER");
b.Property<int>("Permission")
.HasColumnType("INTEGER");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SpawnCount")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CheerCount");
b.HasIndex("CreatorPlayerId");
b.ToTable("Inventions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("InstantiationCost")
.HasColumnType("INTEGER");
b.Property<long>("InventionId")
.HasColumnType("INTEGER");
b.Property<int>("LightsCost")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("VersionNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InventionId", "VersionNumber")
.IsUnique();
b.ToTable("InventionVersions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("BannedAt")
.HasColumnType("TEXT");
b.Property<long>("BannedByAdminId")
.HasColumnType("INTEGER");
b.Property<string>("Cidr")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Cidr");
b.ToTable("IpBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "ItemKey", "ItemType")
.IsUnique();
b.ToTable("ItemWishlists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("EarnedAt")
.HasColumnType("TEXT");
b.Property<string>("EventKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "EventKey");
b.ToTable("Keepsakes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
{
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("Channel")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("LowerIsBetter")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ValueFormat")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("RoomId", "Channel");
b.HasIndex("RoomId");
b.ToTable("LeaderboardChannelMeta");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("StatChannel")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("Value")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomId", "PlayerId", "StatChannel")
.IsUnique();
b.HasIndex("RoomId", "StatChannel", "Value");
b.ToTable("LeaderboardStats");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Context")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("PlatformMask")
.HasColumnType("INTEGER");
b.Property<string>("RoomNamesCsv")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("LoadingScreenTips");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long?>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "SentAt");
b.HasIndex("SenderPlayerId", "SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowAll")
.HasColumnType("INTEGER");
b.Property<bool>("AllowAnnouncements")
.HasColumnType("INTEGER");
b.Property<bool>("AllowEventInvite")
.HasColumnType("INTEGER");
b.Property<bool>("AllowFriendRequest")
.HasColumnType("INTEGER");
b.Property<bool>("AllowMessage")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("NotificationPrefs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClearedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("ObjectiveProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("TEXT");
b.Property<string>("LastIp")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Caption")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("TaggedPlayerIdsCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("UploaderPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("ViewCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("BlobName");
b.HasIndex("CreatedAt");
b.HasIndex("RoomId");
b.HasIndex("UploaderPlayerId");
b.ToTable("Photos");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
.IsUnique();
b.ToTable("PlatformIgnores");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("DeviceId");
b.HasIndex("PlayerId");
b.ToTable("PlayerDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Elo")
.HasColumnType("INTEGER");
b.Property<int>("GameMode")
.HasColumnType("INTEGER");
b.Property<int>("Losses")
.HasColumnType("INTEGER");
b.Property<int>("MatchesPlayed")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Wins")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GameMode", "Elo");
b.HasIndex("PlayerId", "GameMode")
.IsUnique();
b.ToTable("PlayerElo");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("TEXT");
b.Property<string>("BannerImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Birthday")
.HasColumnType("TEXT");
b.Property<bool>("CanReceiveInvites")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedFromDeviceId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DisplayEmoji")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("IdentityFlags")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCommunityTeam")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeveloper")
.HasColumnType("INTEGER");
b.Property<bool>("IsJunior")
.HasColumnType("INTEGER");
b.Property<bool>("IsVerified")
.HasColumnType("INTEGER");
b.Property<string>("LastIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LastPlatform")
.HasColumnType("INTEGER");
b.Property<string>("LastPlatformId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PersonalPronouns")
.HasColumnType("INTEGER");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ProfileImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("XP")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Capacity")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartsAt")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("StartsAt");
b.ToTable("PlayerEvents");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("EventId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Response")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("EventId", "PlayerId")
.IsUnique();
b.ToTable("PlayerEventResponses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("ItemSlug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Quantity")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "ItemSlug")
.IsUnique();
b.ToTable("PlayerInventory");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("PlayerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CustomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsCurated")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsJuniors")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.Property<int>("WarningMask")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("IsCurated");
b.HasIndex("Name");
b.ToTable("Playlists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Cheered")
.HasColumnType("INTEGER");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlaylistId", "PlayerId")
.IsUnique();
b.ToTable("PlaylistInteractions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlaylistId");
b.HasIndex("RoomId");
b.HasIndex("PlaylistId", "OrderIndex");
b.ToTable("PlaylistRooms");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
{
b.Property<long>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DataBlob")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("OwnerPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRegion")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PhotonRoomId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("RoomCode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("OwnerPlayerId");
b.HasIndex("RoomId", "SubRoomId");
b.ToTable("PrivateInstances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
{
b.Property<long>("PrivateInstanceId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("InvitedAt")
.HasColumnType("TEXT");
b.Property<long?>("LatestInviteMessageId")
.HasColumnType("INTEGER");
b.HasKey("PrivateInstanceId", "PlayerId");
b.HasIndex("PlayerId");
b.ToTable("PrivateInstanceInvitees");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("PushTokens");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<bool>("Muted")
.HasColumnType("INTEGER");
b.Property<long>("RequesterId")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RequesterId", "TargetId")
.IsUnique();
b.ToTable("Relationships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Category")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("ResolutionNote")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("TEXT");
b.Property<long?>("ResolverAdminId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("TargetEventId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ReporterPlayerId");
b.HasIndex("ResolvedAt");
b.HasIndex("TargetPlayerId");
b.ToTable("Reports");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BanType")
.HasColumnType("INTEGER");
b.Property<long>("BannedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("BannedPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BannedPlayerId");
b.HasIndex("RoomId", "BannedPlayerId");
b.ToTable("RoomBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "RoomId")
.IsUnique();
b.ToTable("RoomBookmarks");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<Guid?>("CurrencyId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomConsumables");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<Guid>("ConcurrencyCode")
.HasColumnType("TEXT");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomConsumableId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomConsumableId");
b.HasIndex("PlayerId", "RoomConsumableId")
.IsUnique();
b.ToTable("RoomConsumableOwnership");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomCurrencyId");
b.HasIndex("PlayerId", "RoomCurrencyId")
.IsUnique();
b.ToTable("RoomCurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("DailyLimit")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomCurrencies");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomCurrencyId");
b.ToTable("RoomCurrencyPurchaseOffers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<byte[]>("Bytes")
.HasColumnType("BLOB");
b.Property<string>("ReferencedFilenamesCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long?>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<long>("UploadedByPlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("BlobName")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomDataBlobs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<bool>("AllowNewUsers")
.HasColumnType("INTEGER");
b.Property<bool>("AllowsJuniors")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<bool>("CloningAllowed")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("CustomRoomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool>("DisableMicAutoMute")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<bool>("HiddenFromBrowse")
.HasColumnType("INTEGER");
b.Property<double>("HotScore")
.HasColumnType("REAL");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAGRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsDormRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
.HasColumnType("INTEGER");
b.Property<bool>("IsStudioRoom")
.HasColumnType("INTEGER");
b.Property<string>("LoadScreensJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("LocationReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayerCalculationMode")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<string>("PromoExternalContentJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("PromoImagesJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<int>("RoomWarningMask")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<string>("StudioSessionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsMobile")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsVRLow")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("HotScore");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Rooms");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(174)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("RoomId");
b.HasIndex("RoomId", "Name");
b.ToTable("RoomKeys");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("PaidPrice")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<long>("RoomKeyId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("RoomKeyId", "PlayerId")
.IsUnique();
b.ToTable("RoomKeyPurchases");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Accepted")
.HasColumnType("INTEGER");
b.Property<DateTime>("GrantedAt")
.HasColumnType("TEXT");
b.Property<long?>("GrantedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomId");
b.HasIndex("RoomId", "PlayerId", "Role")
.IsUnique();
b.ToTable("RoomRoles");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Override")
.HasColumnType("INTEGER");
b.Property<string>("Permission")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("UpdatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "SubRoomId", "Role", "Permission")
.IsUnique();
b.ToTable("RoomRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CanMatchmakeInto")
.HasColumnType("INTEGER");
b.Property<string>("DataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("DataModifiedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsSandbox")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("RoomSceneLocationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("StudioAssetBundleNamesCsv")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<long?>("StudioSubRoomDataSaveId")
.HasColumnType("INTEGER");
b.Property<string>("StudioUnityAssetId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "Name");
b.HasIndex("RoomId", "OrderIndex")
.IsUnique();
b.ToTable("RoomScenes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("FirstVisitAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastVisitAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("RoomId", "LastVisitAt");
b.HasIndex("RoomId", "PlayerId")
.IsUnique();
b.ToTable("RoomVisits");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChestsOpened")
.HasColumnType("INTEGER");
b.Property<DateTime>("CompletedAt")
.HasColumnType("TEXT");
b.Property<bool>("CustomGame")
.HasColumnType("INTEGER");
b.Property<int>("HealthPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.Property<int>("SecondsInAir")
.HasColumnType("INTEGER");
b.Property<int>("ShieldPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<bool>("WalkGame")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.ToTable("RoyaleMatches");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("MatchId")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("MatchId", "PlayerId");
b.ToTable("RoyaleMatchPlayers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CurrentLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("NextLevelAcornReward")
.HasColumnType("INTEGER");
b.Property<long>("NextLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RankIdx")
.HasColumnType("INTEGER");
b.Property<string>("RankName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("TotalXP")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("RoyalePlayerProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("AllAvatarItemsOwned")
.HasColumnType("INTEGER");
b.Property<string>("BaseRoomNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CharadesSlotBindingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CircuitsV2LimitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DiscoveredGameConfigsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("GlobalFriendsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("PlayMenuTagsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("ProfanityFilterDisabled")
.HasColumnType("INTEGER");
b.Property<string>("RecCenterDoorsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("RoomBlobVersionClampDisabled")
.HasColumnType("INTEGER");
b.Property<bool>("SignupsDisabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WeeklyChallengeRewardJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("WeeklyChallengesCompletedRequired")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("WeeklyChallengesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.HasKey("Id");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Descriptor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("TEXT");
b.Property<long?>("RedeemedByPlayerId")
.HasColumnType("INTEGER");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SignupCodes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("AvailableUntil")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsLimitedTime")
.HasColumnType("INTEGER");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Storefront")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("Storefront");
b.HasIndex("IsActive", "Category");
b.ToTable("StoreItems");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("SubscriberPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SubscriberPlayerId");
b.HasIndex("TargetPlayerId");
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
.IsUnique();
b.ToTable("Subscriptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
{
b.Property<long>("Pk")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssignedPlayerIdsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssignedPlayerNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("JiraBugUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("JiraUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MinNumAssignedPlayers")
.HasColumnType("INTEGER");
b.Property<string>("RoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint?>("TestPassId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Pk");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("Status");
b.HasIndex("TestPassId");
b.ToTable("TestCases");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
{
b.Property<uint>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("WasManuallyClosed")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TestPasses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("UgcPurchasables");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
.WithOne("Avatar")
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
.WithMany("Settings")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
.WithMany("Relationships")
.HasForeignKey("RequesterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Requester");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
{
b.Navigation("Avatar");
b.Navigation("Relationships");
b.Navigation("Settings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DorkNet.Server.Migrations
{
/// <inheritdoc />
public partial class RoomRolePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RoomRolePermissions",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoomId = table.Column<long>(type: "INTEGER", nullable: false),
SubRoomId = table.Column<long>(type: "INTEGER", nullable: false),
Role = table.Column<int>(type: "INTEGER", nullable: false),
Permission = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Override = table.Column<bool>(type: "INTEGER", nullable: false),
Value = table.Column<string>(type: "TEXT", maxLength: 4096, nullable: false),
UpdatedByPlayerId = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RoomRolePermissions", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission",
table: "RoomRolePermissions",
columns: new[] { "RoomId", "SubRoomId", "Role", "Permission" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RoomRolePermissions");
}
}
}
@@ -3021,6 +3021,51 @@ namespace DorkNet.Server.Migrations
b.ToTable("RoomRoles");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Override")
.HasColumnType("INTEGER");
b.Property<string>("Permission")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("UpdatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "SubRoomId", "Role", "Permission")
.IsUnique();
b.ToTable("RoomRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
{
b.Property<long>("Id")
@@ -0,0 +1,97 @@
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace DorkNet.Server.Services;
/// <summary>
/// Reads the client builds CI publishes (<c>ci/downloads/publish.py</c>): a
/// <c>manifest.json</c> plus the files it lists, in a bucket served over plain
/// HTTP (e.g. Garage's web endpoint on the internal network). The site's
/// Downloads and Quest pages fetch them through the server, so the bucket
/// itself never has to be reachable from outside.
///
/// Configured with <c>DorkNet:Downloads:BaseUrl</c> (the folder holding
/// <c>manifest.json</c>) and optionally <c>DorkNet:Downloads:Host</c> (the
/// Host header to send, for web endpoints that pick the bucket by host name).
/// Without a base URL <see cref="IsConfigured"/> is false and the site hides
/// both features.
/// </summary>
public sealed class DownloadsService
{
public const string HttpClientName = "downloads";
// How long the bucket may take to start answering. The body then streams
// with no limit (the client has none; the caller's abort token ends it).
private static readonly TimeSpan HeaderTimeout = TimeSpan.FromSeconds(30);
private readonly IHttpClientFactory _httpClientFactory;
private readonly Uri? _baseUri;
private readonly string? _host;
public DownloadsService(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<DownloadsService> log)
{
_httpClientFactory = httpClientFactory;
var baseUrl = config["DorkNet:Downloads:BaseUrl"]?.Trim();
if (!string.IsNullOrEmpty(baseUrl) && Uri.TryCreate(baseUrl.TrimEnd('/') + "/", UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
_baseUri = uri;
}
var host = config["DorkNet:Downloads:Host"]?.Trim();
_host = string.IsNullOrEmpty(host) ? null : host;
if (_baseUri is null)
log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off");
else
log.LogInformation("[downloads] reading builds from {BaseUrl}{Host}", _baseUri, _host is null ? "" : $" (Host: {_host})");
}
public bool IsConfigured => _baseUri is not null;
/// <summary>The published manifest, or null when none has been published yet.</summary>
public async Task<JsonObject?> GetManifestAsync(CancellationToken ct)
{
using var res = await SendAsync("manifest.json", range: null, ct);
if (res.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
res.EnsureSuccessStatusCode();
return JsonNode.Parse(await res.Content.ReadAsStringAsync(ct)) as JsonObject;
}
/// <summary>A manifest entry by its bucket path, or null when the manifest
/// doesn't list it. Only listed files are ever served.</summary>
public static JsonObject? FindFile(JsonObject manifest, string path)
{
if (manifest["releases"] is not JsonArray releases) return null;
return releases.OfType<JsonObject>()
.SelectMany(r => r["files"] is JsonArray files ? files.OfType<JsonObject>() : [])
.FirstOrDefault(f => string.Equals((string?)f["path"], path, StringComparison.Ordinal));
}
/// <summary>GET a file, forwarding the caller's Range header. The caller
/// owns the response and streams its content.</summary>
public Task<HttpResponseMessage> GetFileAsync(string path, string? range, CancellationToken ct)
=> SendAsync(path, range, ct);
private async Task<HttpResponseMessage> SendAsync(string path, string? range, CancellationToken ct)
{
if (_baseUri is null) throw new InvalidOperationException("Downloads are not configured.");
var url = new Uri(_baseUri, string.Join('/', path.Split('/').Select(Uri.EscapeDataString)));
using var req = new HttpRequestMessage(HttpMethod.Get, url);
if (_host is not null) req.Headers.Host = _host;
if (!string.IsNullOrEmpty(range) && RangeHeaderValue.TryParse(range, out var r)) req.Headers.Range = r;
var client = _httpClientFactory.CreateClient(HttpClientName);
// The timeout covers only getting the headers: with ResponseHeadersRead
// the token is released once they arrive, and disposing the source
// here stops its timer before the body is streamed.
using var headers = CancellationTokenSource.CreateLinkedTokenSource(ct);
headers.CancelAfter(HeaderTimeout);
try
{
return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, headers.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException($"The downloads bucket didn't answer within {HeaderTimeout.TotalSeconds:0} s.");
}
}
}
+12 -1
View File
@@ -41,6 +41,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
player.Level = LevelForXp(player.XP);
await db.SaveChangesAsync();
// Live level bar: "PlayerProgressionLevelUpdate" {PlayerId, Level, XP}
// (handler PBOGNAPEBAI.txt:263, DTO DGFGIFIHCAJ) — the same values
// api/players/v2/progression returns. Without it the watch kept the
// level and XP it loaded at login until the next relog.
await notifications.NotifyTypedAsync(playerId, "PlayerProgressionLevelUpdate",
new { PlayerId = (int)playerId, player.Level, player.XP });
if (player.Level > oldLevel)
{
// Level-up grants a small currency reward. The wallet UI
@@ -82,9 +89,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// StorefrontBalanceUpdate "61" is BalanceResponseDTO {Balance,
// CurrencyType, Platform}; the client sets that (CurrencyType,
// Platform) bucket to Balance. Platform 0 matches every balance REST
// response, so the buckets line up.
await notifications.NotifyAsync(playerId,
PushNotificationId.StorefrontBalanceUpdate,
new { CurrencyType = currencyType, row.Balance, Reason = reason });
new { row.Balance, CurrencyType = currencyType, Platform = 0 });
return row.Balance;
}
+97 -89
View File
@@ -212,25 +212,17 @@ public class NotificationService(
// ── Higher-level event helpers ─────────────────────────────────────
public Task FriendRequestReceived(long targetPlayerId, long fromPlayerId) =>
NotifyAsync(targetPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRequestReceived", From = fromPlayerId });
public Task FriendRequestAccepted(long requesterPlayerId, long byPlayerId) =>
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRequestAccepted", By = byPlayerId });
public Task FriendRequestDeclined(long requesterPlayerId, long byPlayerId) =>
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRequestDeclined", By = byPlayerId });
public Task FriendRemoved(long otherPlayerId, long byPlayerId) =>
NotifyAsync(otherPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRemoved", By = byPlayerId });
public Task FriendBlocked(long blockedPlayerId, long byPlayerId) =>
NotifyAsync(blockedPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendBlocked", By = byPlayerId });
/// <summary>Push one relationship row to <paramref name="recipientPlayerId"/>.
/// <paramref name="relationship"/> must be the recipient's OWN view of the
/// pair — the same <c>{PlayerID, RelationshipType, Muted, Ignored,
/// Favorited}</c> object <c>api/relationships/v2/get</c> returns. The 2023
/// handler (<c>EEGNOHOELBG.FOCBJAIDCBB</c>, Relationship
/// <c>ECGNEHMCGCN</c>) stores it under its <c>PlayerID</c> and raises the
/// changed event, which refreshes the friends list and the client's
/// SubscribeToPlayers set. Any other shape parses as PlayerID 0 and the
/// real row never updates.</summary>
public Task RelationshipChanged(long recipientPlayerId, object relationship) =>
NotifyAsync(recipientPlayerId, PushNotificationId.RelationshipChanged, relationship);
/// <summary>Push a presence delta so subscribed friends re-render
/// their friend-list row without waiting for the watch's 15-45 s
@@ -270,6 +262,23 @@ public class NotificationService(
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
}
/// <summary>Push one player's presence to every connection that subscribed
/// to them with <c>SubscribeToPlayers</c>, except
/// <paramref name="excludeConnectionIds"/> — the subject's own connections
/// (a frame about the local player moves them) and anyone already sent the
/// frame through their friend group.</summary>
public async Task PresenceToSubscribersAsync(
long playerId,
RoomInstanceDto? room,
bool isOnline,
int statusVisibility,
IReadOnlyList<string> excludeConnectionIds)
{
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
await hub.Clients.GroupExcept(NotifyHub.PresenceGroupForPlayer(playerId), excludeConnectionIds)
.SendAsync(HubMethod, payload);
}
/// <summary>The PresenceUpdate envelope for one player, as a JSON string.
///
/// <paramref name="isOnline"/> is independent of the room: the 2023
@@ -302,7 +311,7 @@ public class NotificationService(
{
["roomInstanceId"] = redacted.RoomInstanceId,
["roomId"] = redacted.RoomId,
["subRoomId"] = redacted.SubRoomId,
["subRoomId"] = redacted.WireSubRoomId,
["roomInstanceType"] = redacted.RoomInstanceType,
["location"] = redacted.Location,
["dataBlob"] = redacted.DataBlob,
@@ -349,86 +358,85 @@ public class NotificationService(
public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) =>
PlayerPresenceChanged(friendIds, playerId, room);
/// <summary>Force every currently-connected SignalR session for
/// <paramref name="playerId"/> to disconnect via the watch's
/// ModerationKick flow. Wire path verified against decompilation:
/// <c>PushNotificationId.ModerationKick = 22</c> is registered
/// by <c>LCCEEFHOBEN.OFFLOPLJBBG</c> via the enum overload.
/// <c>LCCEEFHOBEN.KEIAFMLIMFA(Dictionary)</c> reads
/// <c>Msg.Reason</c> as a ModerationBlockDetail object, then
/// calls <c>PODFHPNOHGB</c> / <c>BootLocalPlayerToDormRoom</c>.
///
/// Used by <see cref="Controllers.Match.MatchPlayerController.PlayerLogin"/>
/// to enforce single-session-per-account: when a second login on
/// the same account replaces the LoginLock, the older SignalR
/// connections are kicked. The new session hasn't called
/// <c>negotiate</c> yet (it happens AFTER /player/login on the
/// boot sequence), so the kick only hits old sessions.
/// </summary>
public Task KickStaleSession(long playerId, string reason) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick,
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
/// <summary>Client logout reasons (<c>Logout</c> "6" payload <c>Reason</c>,
/// enum <c>MPJOEALLGHE</c>, read by formatter <c>PBEDEFHAHID</c>). The
/// value must go out as a NUMBER: release builds obfuscate enum names, so a
/// string like "AdminPasswordReset" fails the parse and the whole push is
/// dropped. <c>SessionManager.JNNAOHMNGEM</c> shows its own "signed in
/// elsewhere" text for <see cref="SessionTakeover"/>, then returns the
/// client to the boot scene for every reason.</summary>
public enum LogoutReason
{
Unknown = 0,
UserInitiated = 1,
SessionTakeover = 2,
ForciblyLoggedOut = 3,
Banned = 4,
}
public Task KickPlayerAsync(long playerId, string reason, bool isBan = false) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick,
ModerationKickPayload(reason, isBan, source: "admin_kick"));
/// <summary>Log every session of <paramref name="playerId"/> out to the
/// boot scene. Use this, not a kick, when the player must not stay signed
/// in: a ModerationKick only sends them to their dorm.</summary>
public Task LogoutAsync(long playerId, LogoutReason reason) =>
NotifyAsync(playerId, PushNotificationId.Logout, new { Reason = (int)reason });
/// <summary>Push a ModerationKick to a specific set of SignalR
/// connection ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
/// to evict the previous session when a new device connects on the
/// same account. Targets specific ids (not the player group) so
/// the brand-new connection joining the group right after this
/// returns can't accidentally receive its own eviction notice.
/// Works across replicas because SignalR's Redis backplane routes
/// <summary>Single-session-per-account: when a second login replaces the
/// LoginLock, log the older session out. The new session hasn't called
/// <c>negotiate</c> yet (it happens AFTER /player/login on the boot
/// sequence), so only the old session's connections receive it.</summary>
public Task LogoutStaleSession(long playerId) =>
LogoutAsync(playerId, LogoutReason.SessionTakeover);
/// <summary>The same takeover logout, sent to specific SignalR connection
/// ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
/// to evict the previous session when a new device connects on the same
/// account. Targets ids (not the player group) so the brand-new
/// connection, which joins the group right after, never receives it. Works
/// across replicas because the Redis backplane routes
/// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary>
public Task KickConnectionsAsync(IReadOnlyCollection<string> connectionIds, string reason)
public Task LogoutConnectionsAsync(IReadOnlyCollection<string> connectionIds)
{
if (connectionIds.Count == 0) return Task.CompletedTask;
var payload = Serialize(PushNotificationId.ModerationKick,
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
var payload = Serialize(PushNotificationId.Logout, new { Reason = (int)LogoutReason.SessionTakeover });
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload);
}
private static object ModerationKickPayload(string reason, bool isBan, string source)
/// <summary>Kick <paramref name="playerId"/> back to their dorm with
/// <paramref name="reason"/> shown in the kick dialog. The player stays
/// signed in; use <see cref="LogoutAsync"/> for bans and deletions.
/// Pass the room instance the player is in as
/// <paramref name="gameSessionId"/> to have the client announce the kick
/// to that room.</summary>
public Task KickPlayerAsync(long playerId, string reason, long gameSessionId = 0) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick, ModerationKickPayload(reason, gameSessionId));
/// <summary>ModerationKick "22" payload: the FLAT moderation block
/// <c>PIBLFGPOCIB</c> (formatter <c>LAIJIAIHGCO</c>). The kick handler
/// (<c>SessionManager</c> HandleModerationKicked) starts from its own
/// "Kicked to Dormroom" text and only adds a reason as
/// a "Reason: " line from <c>VoteKickReason</c> when <c>ReportCategory</c> is 10
/// (VoteKick) and <c>IsHostKick</c> is false; <c>Message</c> is never
/// shown. When <c>GameSessionId</c> is the kicked player's current room
/// instance it also broadcasts the kick to that room, and
/// <c>Duration</c> &gt; 0 starts a timeout, so it stays 0. The old nested
/// <c>{Reason:{…}}</c> shape (from the 2020 client) parsed to all defaults,
/// which booted the player but dropped every reason.</summary>
private static object ModerationKickPayload(string reason, long gameSessionId)
{
var message = string.IsNullOrWhiteSpace(reason)
? "Kicked to Dormroom"
: reason.Trim();
var blockDetail = new
{
ReportCategory = 10, // PlayerReporting.ReportCategory.VoteKick
Duration = 0,
GameSessionId = 0L,
Message = message,
IsHostKick = true,
PlayerIdReporter = 1,
IsBan = isBan,
VoteKickReason = message,
};
var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim();
return new
{
Reason = blockDetail,
DisplayReason = message,
BannedByPlayerId = 1,
Source = source,
ReportCategory = 10, // VoteKick: the only category that shows a reason
Duration = 0,
GameSessionId = gameSessionId,
IsHostKick = false,
Message = message,
PlayerIdReporter = (int?)null,
IsBan = false,
IsVoiceModAutoban = false,
IsWarning = false,
VoteKickReason = message,
TimeoutStartedAt = (DateTime?)null,
};
}
/// <summary>Notify a private-instance owner that someone tried to
/// follow them in via "Go to Player" but was rejected for not
/// being on the invite list. Reuses the MessageReceived channel
/// since the watch already renders that as an inbox toast — no
/// new client-side handler needed. The body wording surfaces it
/// as "Player X tried to join your private match" so the owner
/// knows to send an explicit invite if they want to let them in.</summary>
public Task PrivateInstanceJoinRejected(long ownerPlayerId, long attemptedByPlayerId) =>
NotifyAsync(ownerPlayerId, PushNotificationId.MessageReceived,
new
{
Reason = "PrivateInstanceJoinRejected",
AttemptedBy = attemptedByPlayerId,
Preview = $"Player {attemptedByPlayerId} tried to join your private match",
});
}
@@ -148,6 +148,34 @@ public class OnlinePresenceService
lock (set) return set.ToArray();
}
/// <summary>Connection ids of every player in <paramref name="playerIds"/>,
/// in one go: the Redis lookups are issued together, so the multiplexer
/// pipelines them into one round trip instead of one per player.</summary>
public async Task<IReadOnlyCollection<string>> GetConnectionsAsync(IEnumerable<long> playerIds)
{
var ids = playerIds.Distinct().ToArray();
if (ids.Length == 0) return Array.Empty<string>();
if (_redis is { } mux)
{
try
{
var db = mux.GetDatabase();
var sets = await Task.WhenAll(ids.Select(id => db.SetMembersAsync(ConnsKey(id))));
return sets.SelectMany(s => s).Select(v => v.ToString()).ToArray();
}
catch
{
// Redis sick → fall through to the local fallback, as
// GetConnections does.
}
}
var result = new List<string>();
foreach (var id in ids)
if (_localConns.TryGetValue(id, out var set))
lock (set) result.AddRange(set);
return result;
}
/// <summary>Snapshot of currently-online player ids across the
/// cluster. Returns an empty array when nobody's online; never null.
/// Callers typically <c>.ToHashSet()</c> the result for membership
+101 -28
View File
@@ -105,6 +105,15 @@ public class PlayerPresenceService
var db = mux.GetDatabase();
db.StringSet(Key(playerId), json, PresenceTtl);
db.KeyDelete(NoRoomKey(playerId));
// Per-room membership index for the instance browser
// (EnumerateActiveInstances). Members are validated against
// the player's live presence on read, so a player who moved
// on is pruned lazily instead of needing the old room here.
if (room is not null && room.RoomId > 0)
{
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
}
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
playerId, room?.Name ?? "<null>", json.Length);
}
@@ -132,7 +141,19 @@ public class PlayerPresenceService
{
try
{
mux.GetDatabase().KeyExpire(Key(playerId), PresenceTtl);
var db = mux.GetDatabase();
db.KeyExpire(Key(playerId), PresenceTtl);
// Keep the room's membership index alive as long as its
// members are: a player refreshed only through here would
// otherwise outlive the set (SetRoom's 2 × TTL) and drop out
// of the instance browser and room pushes.
var json = db.StringGet(Key(playerId));
if (json.HasValue &&
JsonSerializer.Deserialize<RoomInstanceDto>(json.ToString(), JsonOpts) is { RoomId: > 0 } room)
{
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
}
}
catch (Exception ex)
{
@@ -459,45 +480,97 @@ public class PlayerPresenceService
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
}
/// <summary>List the currently-active instances of a given room,
/// grouped by PhotonRoomId, with the player ids currently presenced
/// in each. Drives the watch's RoomInstanceBrowser
/// (<c>GET match.*/room/{id}/instances</c>) so players can see
/// which instance their friends are in.
/// <summary>List the live instances of a given room, grouped by
/// PhotonRoomId (or RoomInstanceId when a row has none), with the player
/// ids currently presenced in each. Drives the room instance browser
/// (<c>GET match.*/room/{id}/instances</c>) and the event instance list.
///
/// Redis caveat: without a secondary index per room we'd need a
/// SCAN over every presence key — too expensive to do on each
/// request. For now the Redis path returns empty and the local
/// in-process dictionary is the source of truth. Single-instance
/// deployments work; multi-replica Redis deployments need a
/// per-room set index to be added before the browser is reliable
/// there.</summary>
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(long roomId)
/// <para>Only players whose presence is still live count: an expired
/// room presence (<see cref="PresenceTtl"/>) is dropped, and so is a
/// player whose activity marker (<see cref="ActivityTtl"/>, refreshed by
/// every heartbeat and join) has lapsed. A client that crashed keeps its
/// room presence for up to ten minutes, but it is not in the instance and
/// must not inflate the player count or keep an empty instance listed.</para>
///
/// <para><b>Redis.</b> <see cref="SetRoom"/> records each player in a
/// per-room set (<c>presence-room:{roomId}</c>); this reads that set and
/// validates every member against their current presence, pruning the
/// ones that have moved on or expired. Without the index the Redis path
/// had nothing to enumerate and returned an empty list on every
/// multi-replica deployment.</para>
///
/// <para>Private instances are skipped unless
/// <paramref name="includePrivate"/> is set; callers that include them
/// must apply their own visibility rule.</para></summary>
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(
long roomId, bool includePrivate = false)
{
if (_redis is not null) return Array.Empty<(RoomInstanceDto, List<long>)>();
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
foreach (var kv in _local)
var members = new List<(long PlayerId, RoomInstanceDto Room)>();
if (_redis is { } mux)
{
var room = kv.Value;
if (room is null || room.RoomId != roomId) continue;
// Skip private instances — those are surfaced separately
// in the controller via PrivateInstanceService so we never
// leak a photonRoomId for an invite-only match into the
// public browser.
if (room.IsPrivate) continue;
var key = string.IsNullOrEmpty(room.PhotonRoomId)
? $"<{room.RoomInstanceId}>"
: room.PhotonRoomId;
try
{
var db = mux.GetDatabase();
var stale = new List<RedisValue>();
foreach (var member in db.SetMembers(RoomIndexKey(roomId)))
{
if (!long.TryParse(member.ToString(), out var playerId))
{
stale.Add(member);
continue;
}
var room = GetRoom(playerId);
if (room is null || room.RoomId != roomId)
{
stale.Add(member);
continue;
}
members.Add((playerId, room));
}
if (stale.Count > 0)
db.SetRemove(RoomIndexKey(roomId), stale.ToArray());
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[presence] redis room index scan failed room={RoomId}", roomId);
return Array.Empty<(RoomInstanceDto, List<long>)>();
}
}
else
{
foreach (var playerId in _local.Keys.ToList())
{
var room = GetRoom(playerId); // honours the room TTL
if (room is not null && room.RoomId == roomId)
members.Add((playerId, room));
}
}
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
foreach (var (playerId, room) in members)
{
if (room.IsPrivate && !includePrivate) continue;
if (!IsRecentlyActive(playerId)) continue;
var key = InstanceKey(room);
if (!groups.TryGetValue(key, out var entry))
{
entry = (room, new List<long>());
groups[key] = entry;
}
entry.PlayerIds.Add(kv.Key);
entry.PlayerIds.Add(playerId);
}
return groups.Values;
}
/// <summary>The identity two presence rows share when they are in the
/// same live instance: the Photon room, else the instance id.</summary>
public static string InstanceKey(RoomInstanceDto room)
=> string.IsNullOrEmpty(room.PhotonRoomId)
? "<" + room.RoomInstanceId + ">"
: room.PhotonRoomId;
private static string RoomIndexKey(long roomId) => "presence-room:" + roomId;
private static string Key(long playerId) => $"presence:{playerId}";
private static string ActiveKey(long playerId) => $"presence-active:{playerId}";
private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}";
@@ -36,7 +36,10 @@ public static class PresenceVisibility
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so
/// these frames go to friends only, never to the subject.
/// these frames never go to the subject. They go to the subject's friends and to every
/// other connection that subscribed to the subject with SubscribeToPlayers (see
/// NotifyHub.SubscribeToPlayers); non-friends only see the real presence when it is
/// Public.
/// </summary>
public sealed class PresenceBroadcastService(
IServiceScopeFactory scopes,
@@ -58,7 +61,7 @@ public sealed class PresenceBroadcastService(
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
/// <summary>Push the player's current presence (online state + committed
/// room) to every friend.</summary>
/// room) to every friend and subscriber.</summary>
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
/// <summary>Push an offline frame regardless of any lingering markers —
@@ -105,7 +108,6 @@ public sealed class PresenceBroadcastService(
.Where(id => id != playerId)
.Distinct()
.ToList();
if (friendIds.Count == 0) return;
var online = !forceOffline && IsOnline(playerId);
var room = online ? presence.GetRoom(playerId) : null;
@@ -134,6 +136,23 @@ public sealed class PresenceBroadcastService(
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
if (hiddenFrom.Count > 0)
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
// Everyone else who subscribed to this player (SubscribeToPlayers:
// people in the same room, an open profile). They are not friends,
// so they see the real presence only when it is Public, and the
// offline frame otherwise. Friends were served above and the
// subject must never get a frame about themselves, so their
// connections are excluded.
// Only friends who are online have connections; one call for the
// online set, then one pipelined lookup for all of them.
var onlineIds = connections.OnlinePlayerIds().ToHashSet();
var exclude = (await connections.GetConnectionsAsync(
friendIds.Where(onlineIds.Contains).Append(playerId)))
.Distinct()
.ToList();
var publicToAll = visibility == PresenceVisibility.Public && online;
await notifications.PresenceToSubscribersAsync(
playerId, publicToAll ? room : null, publicToAll, visibility, exclude);
}
catch (Exception ex)
{
@@ -0,0 +1,198 @@
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// A sub-room's role-permission matrix: the cells the 2023 client reads from
/// <c>GET photon_access_token</c> (<c>ALIIIAGAPND.Permissions</c>, a list of
/// <c>BNLPBMJJOMM</c>) and writes with
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>.
///
/// <para>The served list is the built-in defaults with every stored cell for
/// the room's sub-room applied on top: a stored cell replaces the default
/// with the same (Role, Permission), and cells with no default are appended.
/// The list must never be empty — with no Permissions the room-permissions
/// runtime (OMJKHJLFOCO) falls into its persisted-role migration path, which
/// NREs during first spawn.</para>
///
/// <para>Cell keys (formatter RecNet.Runtime/HDOJMEBCAGP.txt):
/// <c>Permission</c> (string), <c>Role</c> (OMMBGJMJJPN), <c>Override</c>
/// (bool), <c>Type</c> (FEOJFGECKGH: Bool 0 / Int 1 / StringList 2),
/// <c>Value</c> (string). Role and Type go out as numbers; requests are
/// accepted with numbers, numeric strings or enum names.</para>
/// </summary>
public static class RoomPermissionMatrix
{
/// <summary>One matrix cell.</summary>
public sealed record Cell(string Permission, int Role, bool Override, int Type, string Value);
/// <summary>The defaults every sub-room starts from: Co-Owner and Creator
/// may use the maker pen, spawn inventions and consumables, reset the
/// room and toggle play gizmos.</summary>
public static IReadOnlyList<Cell> Defaults { get; } = BuildDefaults();
private static IReadOnlyList<Cell> BuildDefaults()
{
var permissions = new[]
{
"13", // Can Use Maker Pen
"20", // Can Spawn Inventions
"21", // Can Spawn Consumables
"22", // Can Use Room Reset Button
"23", // Can Use Play Gizmos Toggle
};
var list = new List<Cell>();
foreach (var role in new[] { RoomRoleRules.WireCoOwner, RoomRoleRules.WireCreator })
foreach (var permission in permissions)
list.Add(new Cell(permission, role, true, 0, "true"));
return list;
}
/// <summary>Defaults merged with the stored cells.</summary>
public static List<Cell> Merge(IEnumerable<RoomRolePermissionEntity> stored)
{
var cells = Defaults.ToList();
foreach (var row in stored.OrderBy(r => r.Id))
{
var cell = new Cell(row.Permission, row.Role, row.Override, row.Type, row.Value);
var at = cells.FindIndex(c =>
c.Role == row.Role
&& string.Equals(c.Permission, row.Permission, StringComparison.Ordinal));
if (at >= 0) cells[at] = cell;
else cells.Add(cell);
}
return cells;
}
/// <summary>The served matrix for one sub-room (by OrderIndex).</summary>
public static async Task<List<Cell>> LoadAsync(DorkNetDbContext db, long roomId, long subRoomOrderIndex)
{
var stored = await db.RoomRolePermissions.AsNoTracking()
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomOrderIndex)
.ToListAsync();
return Merge(stored);
}
/// <summary>The <c>ALIIIAGAPND</c> body: <c>{RoomInstanceId,
/// PhotonAccessToken, Permissions}</c>. Used both by
/// <c>GET photon_access_token</c> and the <c>PhotonAccessToken</c> hub
/// push (IBEOONPEELF.txt:519 registers it as Action&lt;ALIIIAGAPND&gt;).</summary>
public static Dictionary<string, object> AccessTokenBody(long roomInstanceId, IEnumerable<Cell> cells)
=> new()
{
["RoomInstanceId"] = roomInstanceId,
["PhotonAccessToken"] = string.Empty,
["Permissions"] = cells.Select(ToWire).ToList(),
};
public static object ToWire(Cell c) => new
{
c.Permission,
c.Role,
c.Override,
c.Type,
c.Value,
};
/// <summary>Parse the PUT body: a JSON array of cells (an object wrapping
/// a <c>Permissions</c> array is accepted too). Returns null when the body
/// is not JSON or holds no array.</summary>
public static List<Cell>? Parse(string body)
{
if (string.IsNullOrWhiteSpace(body)) return null;
JsonDocument doc;
try { doc = JsonDocument.Parse(body); }
catch (JsonException) { return null; }
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
root = root.EnumerateObject()
.Where(p => p.Name.Equals("Permissions", StringComparison.OrdinalIgnoreCase))
.Select(p => p.Value)
.FirstOrDefault();
}
if (root.ValueKind != JsonValueKind.Array) return null;
var cells = new List<Cell>();
foreach (var item in root.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;
string? permission = null;
int? role = null;
bool @override = false;
int type = 0;
string value = string.Empty;
foreach (var prop in item.EnumerateObject())
{
switch (prop.Name.ToLowerInvariant())
{
case "permission":
permission = Scalar(prop.Value)?.Trim();
break;
case "role":
role = EnumValue(prop.Value, RoleName);
break;
case "type":
type = EnumValue(prop.Value, TypeName) ?? 0;
break;
case "override":
@override = prop.Value.ValueKind == JsonValueKind.True
|| (prop.Value.ValueKind == JsonValueKind.String
&& bool.TryParse(prop.Value.GetString(), out var b) && b);
break;
case "value":
value = Scalar(prop.Value) ?? string.Empty;
break;
}
}
if (string.IsNullOrEmpty(permission) || role is null) continue;
if (permission.Length > 128 || value.Length > 4096) continue;
cells.Add(new Cell(permission, role.Value, @override, type, value));
}
return cells;
}
}
private static string? Scalar(JsonElement e) => e.ValueKind switch
{
JsonValueKind.String => e.GetString(),
JsonValueKind.Number => e.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null or JsonValueKind.Undefined => null,
_ => e.GetRawText(),
};
private static int? EnumValue(JsonElement e, Func<string, int?> byName)
{
if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var n)) return n;
if (e.ValueKind != JsonValueKind.String) return null;
var s = e.GetString() ?? string.Empty;
return int.TryParse(s, out n) ? n : byName(s);
}
private static int? RoleName(string s) => s.Trim().ToLowerInvariant() switch
{
"none" => RoomRoleRules.WireNone,
"banned" => RoomRoleRules.WireBanned,
"host" => RoomRoleRules.WireHost,
"moderator" => RoomRoleRules.WireModerator,
"coowner" => RoomRoleRules.WireCoOwner,
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
"creator" => RoomRoleRules.WireCreator,
_ => null,
};
private static int? TypeName(string s) => s.Trim().ToLowerInvariant() switch
{
"bool" => 0,
"int" => 1,
"stringlist" => 2,
_ => null,
};
}
+222
View File
@@ -0,0 +1,222 @@
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// The 2023 room-role ladder and who may change whose role.
///
/// <para><b>Wire enum.</b> The client's room role is <c>OMMBGJMJJPN : byte</c>
/// (recnet-runtime-decomp/OMMBGJMJJPN.cs): None 0, Banned 1, Host 10,
/// Moderator 20, CoOwner 30, TemporaryCoOwner 31, Creator 255. It is
/// gap-skipping, like the club MembershipType enum, so never treat it as an
/// index. <see cref="RoomRoleEntity.Role"/> stores a compact kind instead
/// (0 = CoOwner, 1 = Moderator, 2 = Host); <see cref="ToWire"/> and
/// <see cref="TryFromWire"/> convert.</para>
///
/// <para><b>The creator is 255, not 30.</b> The client reads the local
/// player's role straight out of the room details <c>Roles</c> list
/// (<c>FGCPNAACHIK.ONECLLALJEO</c>: first row whose AccountId matches), and
/// the room-permissions runtime only lets a player edit a role strictly
/// below their own, with Creator (255) the one unconditional pass
/// (<c>OMJKHJLFOCO.DIIMLMKEJLC</c>,
/// IsilDump/RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367:
/// <c>if role == 255 return true; if target &gt;= role return false</c>).
/// Advertising the creator as CoOwner (30) therefore locked the room's own
/// creator out of the Co-Owner and Creator permission rows.</para>
///
/// <para><b>Server rule</b> (<see cref="CheckChange"/>): the creator may set
/// any role on anyone but themselves. Otherwise the actor must hold an
/// accepted CoOwner role, and both the target's current role (accepted or
/// still invited) and the requested role must be strictly below the
/// actor's. A player may always drop their own role or decline their own
/// invite (role 0), and may accept exactly the role they were invited to.</para>
/// </summary>
public static class RoomRoleRules
{
public const int WireNone = 0;
public const int WireBanned = 1;
public const int WireHost = 10;
public const int WireModerator = 20;
public const int WireCoOwner = 30;
public const int WireTemporaryCoOwner = 31;
public const int WireCreator = 255;
public const int StoredCoOwner = 0;
public const int StoredModerator = 1;
public const int StoredHost = 2;
/// <summary>Stored kind → wire role.</summary>
public static int ToWire(int storedRole) => storedRole switch
{
StoredCoOwner => WireCoOwner,
StoredModerator => WireModerator,
StoredHost => WireHost,
_ => WireNone,
};
/// <summary>Wire role → stored kind. Only the three persistable roles
/// map; None, Banned, TemporaryCoOwner (an in-instance grant) and Creator
/// (ownership, changed through <c>rooms/{id}/creator</c>) do not.</summary>
public static bool TryFromWire(int wireRole, out int storedRole)
{
storedRole = wireRole switch
{
WireCoOwner => StoredCoOwner,
WireModerator => StoredModerator,
WireHost => StoredHost,
_ => -1,
};
return storedRole >= 0;
}
/// <summary>The player's accepted wire role in the room: 255 for the
/// creator, the highest accepted grant otherwise, 0 for none.</summary>
public static int AcceptedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
{
if (room.CreatorPlayerId == playerId) return WireCreator;
return rows
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Accepted)
.Select(r => ToWire(r.Role))
.DefaultIfEmpty(WireNone)
.Max();
}
/// <summary>The highest role the player has been invited to and not yet
/// accepted, 0 for none.</summary>
public static int InvitedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
=> rows
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && !r.Accepted)
.Select(r => ToWire(r.Role))
.DefaultIfEmpty(WireNone)
.Max();
/// <summary>True when the player may change room-level settings and the
/// sub-room settings that hang off them: the creator or an accepted
/// co-owner. This is the same gate as the bare room mutations
/// (RoomsModerationController.RequireOwnedRoomAsync) and room saves.</summary>
public static bool CanEditRoom(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
=> AcceptedRank(room, rows, playerId) >= WireCoOwner;
/// <summary>What a role write will do once <see cref="CheckChange"/> allows it.</summary>
public enum ChangeKind
{
/// <summary>Grant or replace the target's role (accepted at once).</summary>
Grant,
/// <summary>Record a pending invite the target must accept.</summary>
Invite,
/// <summary>The target accepts their own pending invite.</summary>
AcceptInvite,
/// <summary>Remove the target's role and any pending invite.</summary>
Clear,
}
/// <summary>Decide whether <paramref name="actorId"/> may set
/// <paramref name="targetId"/>'s role to <paramref name="requestedWireRole"/>.
/// Returns null when allowed, otherwise an error code for the response.
/// <paramref name="kind"/> is what the caller should then do.</summary>
public static string? CheckChange(
RoomEntity room,
IReadOnlyCollection<RoomRoleEntity> rows,
long actorId,
long targetId,
int requestedWireRole,
bool isInvite,
out ChangeKind kind)
{
kind = requestedWireRole == WireNone
? ChangeKind.Clear
: isInvite ? ChangeKind.Invite : ChangeKind.Grant;
if (targetId == room.CreatorPlayerId)
return "cannot_change_creator_role";
if (requestedWireRole != WireNone && !TryFromWire(requestedWireRole, out _))
return "invalid_role";
var actorRank = AcceptedRank(room, rows, actorId);
// Self-service: anyone may give up their own role or decline their
// own invite, and accept the exact role they were invited to.
if (actorId == targetId && actorRank != WireCreator)
{
if (requestedWireRole == WireNone) return null;
if (!isInvite && requestedWireRole == InvitedRank(room, rows, targetId))
{
kind = ChangeKind.AcceptInvite;
return null;
}
return "forbidden";
}
if (actorRank == WireCreator) return null;
if (actorRank < WireCoOwner) return "forbidden";
var targetRank = Math.Max(
AcceptedRank(room, rows, targetId),
InvitedRank(room, rows, targetId));
if (targetRank >= actorRank) return "forbidden";
if (requestedWireRole >= actorRank) return "forbidden";
return null;
}
/// <summary>True when <paramref name="playerId"/> may write the
/// permission row for <paramref name="wireRole"/>: the creator may edit
/// every role (including CoOwner and Creator); a co-owner only the roles
/// below their own. Mirrors the client's OMJKHJLFOCO.DIIMLMKEJLC.</summary>
public static bool CanEditRolePermissions(
RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId, int wireRole)
{
var rank = AcceptedRank(room, rows, playerId);
if (rank == WireCreator) return true;
if (rank < WireCoOwner) return false;
// TemporaryCoOwner collapses to CoOwner on the client (ONECLLALJEO).
var target = wireRole == WireTemporaryCoOwner ? WireCoOwner : wireRole;
return target < rank;
}
/// <summary>The room details / roles-list rows: one per account, with
/// the creator first as 255 and every other account's accepted role in
/// <c>Role</c> and pending invite in <c>InvitedRole</c>. Rows the
/// creator happens to own in RoomRoles are ignored so the creator is
/// never listed twice (the client takes the first match).</summary>
public static List<RoomRoleWire> BuildWires(RoomEntity room, IEnumerable<RoomRoleEntity> rows)
{
var list = new List<RoomRoleWire>
{
new(ToClientAccountId(room.CreatorPlayerId), WireCreator, WireNone, null),
};
foreach (var group in rows
.Where(r => r.RoomId == room.Id && r.PlayerId != room.CreatorPlayerId)
.GroupBy(r => r.PlayerId)
.OrderBy(g => g.Key))
{
var accepted = group.Where(r => r.Accepted).ToList();
var invited = group.Where(r => !r.Accepted).ToList();
var role = accepted.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max();
var invitedRole = invited.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max();
var lastChangedBy = group
.OrderByDescending(r => r.GrantedAt)
.Select(r => r.GrantedByPlayerId)
.FirstOrDefault();
list.Add(new RoomRoleWire(
ToClientAccountId(group.Key),
role,
invitedRole,
lastChangedBy is long by ? ToClientAccountId(by) : null));
}
return list;
}
/// <summary>One <c>EFHPLDPNGIM</c> row: <c>{AccountId: Int32, Role,
/// InvitedRole, LastChangedByAccountId: Int32?}</c>.</summary>
public sealed record RoomRoleWire(int AccountId, int Role, int InvitedRole, int? LastChangedByAccountId);
/// <summary>Account ids are Int32 in the 2023 role DTO. Clamp rather than
/// wrap so an out-of-range id is obviously wrong.</summary>
public static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
}
+44
View File
@@ -0,0 +1,44 @@
namespace DorkNet.Server.Services;
/// <summary>
/// Converts between a sub-room's position in the room
/// (<see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>, 0-based, what the
/// server stores and indexes on) and the <c>SubRoomId</c> the 2023 client
/// sees on the wire.
///
/// <para><b>Why they differ.</b> The client refuses to load a sub-room whose
/// id is not positive: <c>SubroomModel.DOKCPLJOKIE</c>
/// (IsilDump/Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) is
/// <c>RoomId &gt; 0 &amp;&amp; SubroomId &gt; 0</c>, and
/// <c>Model`1.LNPEMMMGCPC</c> skips the load when it is false. With the
/// first sub-room advertised as id 0, its Room Settings &gt; Subrooms row
/// never fetched its details (<c>SubroomModel.KBOJBJNCOGK</c> →
/// <c>FGCPNAACHIK.OBFPEGLLELP(long?)</c>), so the row showed no name, and
/// every per-row action (rename, max players, permissions, duplicate) ran
/// against a null sub-room. Rec Room's own ids were large unique numbers;
/// the client needs only "positive and stable per room".</para>
///
/// <para><b>Mapping.</b> Wire id = OrderIndex + 1, so a room's first
/// sub-room is 1. Incoming ids go back through <see cref="ToOrderIndex"/>;
/// 0 (and anything negative) is accepted as an alias of the first sub-room,
/// which keeps cached client state, old presence rows and bare
/// <c>matchmake/room/{id}</c> joins working.</para>
///
/// <para><b>Where the conversion happens.</b> Only at the wire: the room
/// details <c>SubRooms[]</c>, the sub-room routes
/// (<c>rooms/{id}/subrooms/{sub}/*</c>), <c>matchmake/room/{id}/{sub}</c>,
/// the room-instance JSON (<c>RoomInstanceDto.subRoomId</c>), the instance
/// browser, sub-room save DTOs and permission rows. Everything stored
/// (RoomScenes, PrivateInstances, RoomDataBlobs, presence) keeps the
/// OrderIndex.</para>
/// </summary>
public static class RoomSceneIds
{
/// <summary>OrderIndex → wire SubRoomId (always ≥ 1).</summary>
public static long ToWire(long orderIndex) => Math.Max(0, orderIndex) + 1;
/// <summary>Wire SubRoomId → OrderIndex. 0 and negatives map to the first
/// sub-room.</summary>
public static int ToOrderIndex(long wireSubRoomId)
=> wireSubRoomId <= 1 ? 0 : (int)Math.Min(int.MaxValue, wireSubRoomId - 1);
}
+110 -1
View File
@@ -546,6 +546,94 @@ public class RoomService(DorkNetDbContext db)
return existing;
}
/// <summary>The name a sub-room gets when it has none: "Home" for the
/// entry sub-room, "Subroom N" (1-based, matching its wire id) for the
/// rest.</summary>
public static string DefaultSubRoomName(int orderIndex)
=> orderIndex <= 0 ? "Home" : $"Subroom {orderIndex + 1}";
/// <summary>A real entry sub-room row for a room that has none, built
/// from the room's own location, data blob and capacity. Callers add it
/// to the context.</summary>
public static RoomSceneEntity NewHomeScene(RoomEntity room) => new()
{
RoomId = room.Id,
OrderIndex = 0,
Name = DefaultSubRoomName(0),
RoomSceneLocationId = string.IsNullOrWhiteSpace(room.LocationReplicationId)
? new RoomSceneEntity().RoomSceneLocationId
: room.LocationReplicationId,
DataBlobName = room.CurrentDataBlobName ?? string.Empty,
MaxPlayers = room.MaxCapacity > 0 ? room.MaxCapacity : 8,
IsSandbox = false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
};
/// <summary>Postgres only: moves the RoomScenes id sequence past the
/// highest existing id. Rows written with explicit ids (seeds, older
/// builds of the clone and repair code) leave it behind, and the next
/// insert that relies on it (add / clone sub-room) then collides on the
/// primary key. A no-op on SQLite, which assigns max + 1 itself.</summary>
public async Task SyncRoomSceneIdSequenceAsync()
{
if (!db.Database.IsNpgsql()) return;
await db.Database.ExecuteSqlRawAsync(
"""
SELECT setval(pg_get_serial_sequence('"RoomScenes"', 'Id'),
GREATEST(COALESCE((SELECT MAX("Id") FROM "RoomScenes"), 0), 1),
EXISTS (SELECT 1 FROM "RoomScenes"));
""");
}
/// <summary>Startup self-heal for sub-rooms (idempotent).
///
/// <para>Every non-dorm room must own at least one real RoomScenes row,
/// and every row must have a name. A room created by cloning a template
/// that had no scene rows (the 2023 "create room" flow clones a base room
/// such as MakerRoom) ended up with none, so the details builder served a
/// synthesised fallback and every sub-room mutation had to create the row
/// on the fly; a blank Name renders as an empty row in Room Settings &gt;
/// Subrooms. This inserts the missing entry sub-room (OrderIndex 0, the
/// room's own location / blob / capacity) and names unnamed rows. Dorms
/// are left alone: their details are shaped per player and their single
/// scene is synthesised on purpose.</para>
///
/// <para>It first moves the Postgres id sequence of RoomScenes past the
/// highest id (<see cref="SyncRoomSceneIdSequenceAsync"/>), then lets the
/// database assign ids like every other scene insert. Every service that
/// runs the database bootstrap calls this at startup, so on Postgres the
/// whole repair holds the bootstrap's advisory lock and replicas booting
/// together take turns.</para>
/// Returns (rooms given an entry scene, scenes renamed).</summary>
public async Task<(int ScenesAdded, int ScenesRenamed)> RepairRoomScenesAsync()
{
await using var tx = await db.Database.BeginTransactionAsync();
if (db.Database.IsNpgsql())
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(1146246987);");
// The entry sub-room is OrderIndex 0 (its blob is the room's own front
// door), so a room missing it is repaired even when it has others.
await SyncRoomSceneIdSequenceAsync();
var roomsWithoutEntry = await db.Rooms
.Where(r => !r.IsDormRoom && !db.RoomScenes.Any(s => s.RoomId == r.Id && s.OrderIndex == 0))
.ToListAsync();
foreach (var room in roomsWithoutEntry)
db.RoomScenes.Add(NewHomeScene(room));
var unnamed = await db.RoomScenes
.Where(s => s.Name == null || s.Name.Trim() == string.Empty)
.ToListAsync();
foreach (var scene in unnamed)
scene.Name = DefaultSubRoomName(scene.OrderIndex);
if (roomsWithoutEntry.Count > 0 || unnamed.Count > 0)
await db.SaveChangesAsync();
await tx.CommitAsync();
return (roomsWithoutEntry.Count, unnamed.Count);
}
/// <summary>Run once at startup to backfill <see cref="RoomEntity"/>
/// + <see cref="DormStateEntity"/> rows for every account that
/// pre-dates the auto-create-on-signup work. Idempotent — calls
@@ -1068,6 +1156,8 @@ public class RoomService(DorkNetDbContext db)
};
db.Rooms.Add(clone);
// Every room owns a real entry sub-room (see RepairRoomScenesAsync).
db.RoomScenes.Add(NewHomeScene(clone));
await db.SaveChangesAsync();
return new CloneResult(0 /* Success */, clone);
}
@@ -1081,6 +1171,25 @@ public class RoomService(DorkNetDbContext db)
/// AllowsJuniors, RoomWarningMask, CustomRoomWarning
/// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute.
/// </summary>
/// <summary>The <c>IsRRO</c> the 2023 client is told: only a first-party
/// room (baked/AG and still owned by the system account) is a Rec Room
/// Original. A player's room is never one, even when it was cloned from an
/// RRO template and keeps <see cref="RoomEntity.IsAGRoom"/> for the baked
/// blob handling.
///
/// <para>The client gates the whole owner UI on it: RoomModel
/// get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom,
/// CanChangeRoomCreator, CanChangeRoomSetting, CanChangeCustomWarning,
/// CanChangeLoadingScreen and CanCreateNewSubroom all require
/// <c>!IsRRO</c> (IsilDump/Assembly-CSharp/RRUI/Data/RoomModel.txt:275,
/// 429, 560, 682, 1002, 1473, 1680, 1816), so a clone advertised as RRO
/// could never be published ("Cannot publish room") or have its settings
/// changed. The room-load pipeline (RecRoom.RoomLoading.Runtime) never
/// reads the flag: the scene comes from the sub-room's UnitySceneId and
/// the blob step from whether DataBlob is set.</para></summary>
public static bool WireIsRRO(RoomEntity r)
=> r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId;
public static object ToWireRoom(RoomEntity r) => new
{
RoomId = r.Id,
@@ -1093,7 +1202,7 @@ public class RoomService(DorkNetDbContext db)
Accessibility = r.Accessibility,
SupportsLevelVoting = r.SupportsLevelVoting,
IsAGRoom = r.IsAGRoom,
IsRRO = r.IsAGRoom,
IsRRO = WireIsRRO(r),
// 2020.12 Room.Deserialize (KLCOGEIGEBJ.PPGFHEDFBEA, the Room base)
// reads required keys: RoomId, IsDorm, CloningAllowed, DisableMicAutoMute,
// DisableRoomComments, EncryptVoiceChat. The 2020.03 key was IsDormRoom;
@@ -28,6 +28,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
/// listed; add more as needed.</summary>
public static class MessageType
{
public const int FriendInvite = 4;
public const int RequestGameInvite = 10;
public const int TextMessage = 30;
public const int FriendRequestAccepted = 40;
public const int FriendCodeUsed = 41;
@@ -39,6 +41,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
public const int CreatorPublishedNewRoom = 70;
public const int PlayerAttendingEvent = 80;
public const int PlayerEventInvitation = 81;
public const int CoachMessage = 100;
public const int FriendIntroduction = 130;
}
/// <summary>Persist and push a system notification to
+138 -52
View File
@@ -15,9 +15,9 @@ namespace DorkNet.Server.Startup;
/// <item>SQLite — <c>Database.Migrate()</c> after
/// <see cref="BaselineExistingSchemaIfNeeded"/> reconciles any legacy
/// migration-history state.</item>
/// <item>Postgres — <c>EnsureCreated</c> behind a transaction-scoped
/// advisory lock so concurrent replica boots don't race on
/// <c>CREATE TABLE</c>. Connect retries cover the
/// <item>Postgres — <c>EnsureCreated</c>, with the whole bootstrap
/// (schema and seeds) behind a session advisory lock so concurrent
/// replica boots take turns instead of racing. Connect retries cover the
/// container-orchestrated boot window where Postgres DNS isn't
/// resolvable yet.</item>
/// </list>
@@ -40,61 +40,110 @@ public static class DatabaseBootstrap
await ConfigureSqliteForLocalConcurrencyAsync(db);
BaselineExistingSchemaIfNeeded(db);
db.Database.Migrate();
await RunUpgradesAndSeedsAsync(app, scope, db);
return;
}
else
// Postgres path: schema generated from the EF Core model via
// EnsureCreated, then the same upgrades and seeds as SQLite. The
// whole bootstrap holds a session-level advisory lock, so replicas
// booting against one database take turns: the first creates the
// schema and seeds, the rest find everything in place. Holding it
// only around EnsureCreated left the seeders racing (duplicate keys
// on a fresh database).
//
// Lock key 0x444F524B (ascii "DORK") is hard-coded; it just needs
// to be the same int64 across replicas and not collide with
// anything else in the database. It is released in the finally
// below, and Postgres drops it if the replica's connection dies, so
// a crashed replica can't wedge the next boot.
var conn = db.Database.GetDbConnection();
// Retry initial Postgres connect — on container-orchestrated boots
// (Coolify, docker-compose) the Postgres service's DNS often isn't
// resolvable yet when this app starts, surfacing as SocketException
// errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore.
// Postgres itself can also be up-but-not-accepting-connections for
// a few seconds while it replays WAL. 15 attempts × ~2s backoff
// covers the typical 5-30s warmup window without looping forever
// on a permanently broken config.
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.Bootstrap");
for (var attempt = 1; ; attempt++)
{
// Postgres path: schema generated from the EF Core model via
// EnsureCreated. Wrapped in a transaction-scoped advisory lock
// so concurrent replicas booting against the same database
// don't race on CREATE TABLE — only the first replica to take
// the lock runs EnsureCreated, the rest see "schema already
// exists" and proceed.
//
// Lock key 0x444F524B (ascii "DORK") is hard-coded; it just
// needs to be the same int64 across replicas and not collide
// with anything else in the database. pg_advisory_xact_lock
// releases automatically on COMMIT, so a crashed replica can't
// wedge the next boot.
var conn = db.Database.GetDbConnection();
// Retry initial Postgres connect — on container-orchestrated boots
// (Coolify, docker-compose) the Postgres service's DNS often isn't
// resolvable yet when this app starts, surfacing as SocketException
// errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore.
// Postgres itself can also be up-but-not-accepting-connections for
// a few seconds while it replays WAL. 15 attempts × ~2s backoff
// covers the typical 5-30s warmup window without looping forever
// on a permanently broken config.
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.Bootstrap");
for (var attempt = 1; ; attempt++)
try { await conn.OpenAsync(); break; }
catch (Exception ex) when (attempt < 15 &&
(ex is System.Net.Sockets.SocketException
|| ex is Npgsql.NpgsqlException
|| ex is System.Data.Common.DbException))
{
try { await conn.OpenAsync(); break; }
catch (Exception ex) when (attempt < 15 &&
(ex is System.Net.Sockets.SocketException
|| ex is Npgsql.NpgsqlException
|| ex is System.Data.Common.DbException))
{
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
connectLogger.LogWarning(
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
await Task.Delay(delay);
}
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
connectLogger.LogWarning(
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
await Task.Delay(delay);
}
await using var tx = await conn.BeginTransactionAsync();
await using (var lockCmd = conn.CreateCommand())
{
lockCmd.Transaction = tx;
lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);";
await lockCmd.ExecuteNonQueryAsync();
}
db.Database.EnsureCreated();
await tx.CommitAsync();
await conn.CloseAsync();
}
// The connection stays open until the finally, so every step below
// (they all share this scope's DbContext) runs on the session that
// holds the lock. Waiting for it has no command timeout: another
// replica may be seeding a fresh database (thumbnail downloads
// included) for well over the default 30 s.
connectLogger.LogInformation("[bootstrap] waiting for the database bootstrap lock");
var previousTimeout = db.Database.GetCommandTimeout();
db.Database.SetCommandTimeout(0);
try
{
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_lock(1146246987);");
}
finally
{
db.Database.SetCommandTimeout(previousTimeout);
}
connectLogger.LogInformation("[bootstrap] holding the database bootstrap lock");
try
{
// Begin the transaction through EF, not on the raw connection:
// EnsureCreated runs its commands through the migration executor,
// which starts its own transaction unless EF already has one, and
// fails on a fresh database with "A transaction is already in
// progress" when the open one is invisible to it.
await using (var tx = await db.Database.BeginTransactionAsync())
{
db.Database.EnsureCreated();
await tx.CommitAsync();
}
await RunUpgradesAndSeedsAsync(app, scope, db);
}
finally
{
// Explicit: Npgsql pools connections, so closing alone would not
// end the session that holds the lock. If the connection is
// already broken the unlock fails too; that must not replace the
// real bootstrap error, and the connection still gets closed
// (Postgres then drops the lock with the session).
try
{
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_unlock(1146246987);");
}
catch (Exception ex)
{
connectLogger.LogWarning(ex, "[bootstrap] releasing the database bootstrap lock failed");
}
finally
{
await conn.CloseAsync();
}
}
}
/// <summary>The idempotent post-schema sequence both providers run:
/// column/table upgrades, system account and backfills, legacy data
/// transforms, canonical rooms and the per-feature seeders. Flips the
/// <c>/healthz</c> readiness flag at the end.</summary>
private static async Task RunUpgradesAndSeedsAsync(WebApplication app, IServiceScope scope, DorkNetDbContext db)
{
// Signup-code + pending-device tables. These post-date the
// consolidated Initial migration, and the Postgres path is
// EnsureCreated-only (no migration replay), so neither provider
@@ -164,6 +213,11 @@ public static class DatabaseBootstrap
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
}
// Every non-dorm room needs a real, named entry sub-room (see
// RoomService.RepairRoomScenesAsync). Runs after the seed and the
// canonical overrides so it only fills what they left missing.
await roomService.RepairRoomScenesAsync();
// Seed default curated playlists, club category tags, store catalog.
// Must run after SeedAsync so they can reference RR-Originals.
var playlistService = scope.ServiceProvider.GetRequiredService<PlaylistService>();
@@ -899,6 +953,23 @@ public static class DatabaseBootstrap
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
// 2023 sub-room role-permission matrix
// (PUT rooms/{id}/subrooms/{sub}/permissions → photon_access_token).
"""
CREATE TABLE IF NOT EXISTS "RoomRolePermissions" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"RoomId" bigint NOT NULL,
"SubRoomId" bigint NOT NULL,
"Role" integer NOT NULL,
"Permission" character varying(128) NOT NULL DEFAULT '',
"Type" integer NOT NULL DEFAULT 0,
"Override" boolean NOT NULL DEFAULT false,
"Value" character varying(4096) NOT NULL DEFAULT '',
"UpdatedByPlayerId" bigint NOT NULL DEFAULT 0,
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");",
};
foreach (var sql in statements)
await db.Database.ExecuteSqlRawAsync(sql);
@@ -976,6 +1047,21 @@ public static class DatabaseBootstrap
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
"""
CREATE TABLE IF NOT EXISTS "RoomRolePermissions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomRolePermissions" PRIMARY KEY AUTOINCREMENT,
"RoomId" INTEGER NOT NULL,
"SubRoomId" INTEGER NOT NULL,
"Role" INTEGER NOT NULL,
"Permission" TEXT NOT NULL DEFAULT '',
"Type" INTEGER NOT NULL DEFAULT 0,
"Override" INTEGER NOT NULL DEFAULT 0,
"Value" TEXT NOT NULL DEFAULT '',
"UpdatedByPlayerId" INTEGER NOT NULL DEFAULT 0,
"UpdatedAt" TEXT NOT NULL DEFAULT ''
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");",
};
// CREATE ... IF NOT EXISTS is already idempotent; a failure here is a
// real problem and must fail startup.
@@ -238,6 +238,9 @@ public static class RequestTracingExtensions
return false;
var path = request.Path.Value ?? "";
// Client builds are streamed through (several GB): never buffer them.
if (path.StartsWith("/api/site/v1/downloads/files/", StringComparison.OrdinalIgnoreCase))
return false;
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext))
return true;
@@ -217,6 +217,13 @@ public static class ServiceCollectionExtensions
// so a deployment with no GitHub credentials behaves normally.
services.AddSingleton<IGitHubIssues, GitHubIssueService>();
services.AddHostedService<TestCaseIssueReconciler>();
// CI-published client builds for the site's Downloads / Quest pages.
// Off (IsConfigured=false) without DorkNet:Downloads:BaseUrl. No client
// timeout: a multi-GB file streams for longer than the default 100 s.
// DownloadsService limits the wait for headers itself, and the
// request's own abort token ends the stream.
services.AddHttpClient(DownloadsService.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan);
services.AddSingleton<DownloadsService>();
// Scoped (per-request) — these services hold DbContext references and
// must share the request's scope to participate in the request's
// transaction lifecycle. Pre-PR-3 they were Singleton with in-process
@@ -416,15 +423,18 @@ public static class ServiceCollectionExtensions
kopts.Limits.MinResponseDataRate = null;
});
// Keepalive cadence: server pings every 10s, gives the client 60s
// to ack before declaring the connection dead. The 10s ping forces
// frame traffic well below every proxy timeout we've seen in the
// path (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik
// 180s, Kestrel 130s).
// Keepalive cadence: the server pings every 10s, which keeps frame
// traffic well below every proxy timeout we've seen in the path
// (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik 180s,
// Kestrel 130s). The 2023 client pings every 15s, but from its main
// thread, so a long room-load hitch pauses its pings. It has no
// timeout of its own and reconnects at most 3 times per login, so a
// healthy socket the server drops can leave it without live
// notifications for the rest of the session. Give it 180s.
var signalR = builder.Services.AddSignalR(opts =>
{
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(180);
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
// Allow large gift / community-board payloads (default 32 KB
// truncates anything bigger and disconnects the client).
@@ -5,18 +5,32 @@ import { useToast } from '../components/Toast';
export function Broadcast() {
const [message, setMessage] = useState('');
const [minutes, setMinutes] = useState('');
const [busy, setBusy] = useState(false);
const toast = useToast();
const startsInMinutes = minutes.trim() === '' ? null : Number(minutes);
const minutesValid = startsInMinutes === null || (Number.isInteger(startsInMinutes) && startsInMinutes >= 0);
const canSend = !busy && minutesValid && (message.trim() !== '' || startsInMinutes !== null);
const send = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
if (!confirm(`Broadcast this to every connected player?\n\n"${message}"`)) return;
if (!canSend) return;
const parts = [
message.trim() && `"${message.trim()}"`,
startsInMinutes !== null &&
(startsInMinutes === 0 ? 'Cancel the maintenance countdown' : `Maintenance countdown: ${startsInMinutes} min`),
].filter(Boolean);
if (!confirm(`Broadcast this to every connected player?\n\n${parts.join('\n')}`)) return;
setBusy(true);
try {
await api('/broadcast', { method: 'POST', body: { Message: message } });
toast.push('Broadcast sent', 'success');
const res = await api<{ recipients: number }>('/broadcast', {
method: 'POST',
body: { Message: message.trim() || null, StartsInMinutes: startsInMinutes },
});
toast.push(`Broadcast sent to ${res.recipients} player(s)`, 'success');
setMessage('');
setMinutes('');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
@@ -28,7 +42,7 @@ export function Broadcast() {
<div>
<PageHeader
title="Broadcast"
blurb="Push a server-maintenance notification to every connected player. The watch shows it as a system toast."
blurb="Message every connected player. The message lands in their in-game notifications from the system account; the optional countdown starts the game's own maintenance timer."
/>
<form onSubmit={send} className="card !p-5 max-w-2xl">
@@ -39,14 +53,33 @@ export function Broadcast() {
rows={5}
className="input mt-1.5 font-mono text-sm"
placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save."
required
/>
<div className="mt-2 text-xs text-ink-400">
{message.length} characters
</div>
<label className="label mt-4">Maintenance countdown (minutes, optional)</label>
<input
value={minutes}
onChange={e => setMinutes(e.target.value)}
inputMode="numeric"
className="input mt-1.5 w-40 text-sm"
placeholder="e.g. 5"
/>
<div className="mt-2 text-xs text-ink-400">
{minutesValid ? 'Leave empty for no countdown; 0 cancels a running one.' : 'Enter a whole number of minutes.'}
</div>
<div className="mt-4 flex gap-2 justify-end">
<button type="button" onClick={() => setMessage('')} className="btn-ghost text-xs" disabled={busy || !message}>Clear</button>
<button className="btn-primary text-xs" disabled={busy || !message.trim()}>
<button
type="button"
onClick={() => { setMessage(''); setMinutes(''); }}
className="btn-ghost text-xs"
disabled={busy || (!message && !minutes)}
>
Clear
</button>
<button className="btn-primary text-xs" disabled={!canSend}>
{busy ? 'Sending…' : 'Send broadcast'}
</button>
</div>
+2
View File
@@ -8,6 +8,7 @@ import { Players } from './pages/Players';
import { PlayerProfile } from './pages/PlayerProfile';
import { Rooms } from './pages/Rooms';
import { Join } from './pages/Join';
import { Downloads } from './pages/Downloads';
import { NotFound } from './pages/NotFound';
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
@@ -27,6 +28,7 @@ export function App() {
<Route path="/rooms" element={<Rooms />} />
<Route path="/join" element={<Join />} />
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
<Route path="/downloads" element={<Downloads />} />
{/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} />
+11 -2
View File
@@ -1,6 +1,7 @@
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
import { useEffect, useRef, useState } from 'react';
import { BrandMark } from './BrandMark';
import { getDownloads } from '../lib/downloads';
const NAV = [
{ to: '/', label: 'Home', exact: true },
@@ -10,6 +11,8 @@ const NAV = [
{ to: '/join', label: 'Join' },
{ to: '/quest', label: 'Quest' },
];
// Shown only when the server offers downloads (DorkNet:Downloads:BaseUrl).
const DOWNLOADS = { to: '/downloads', label: 'Downloads', exact: false };
export function Header() {
const [open, setOpen] = useState(false);
@@ -17,6 +20,12 @@ export function Header() {
const nav = useNavigate();
const loc = useLocation();
const inputRef = useRef<HTMLInputElement>(null);
const [hasDownloads, setHasDownloads] = useState(false);
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
useEffect(() => {
getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {});
}, []);
// Close the mobile drawer on route change so tapping a nav item
// doesn't leave a half-open overlay covering the page.
@@ -37,7 +46,7 @@ export function Header() {
</Link>
<nav className="hidden md:flex items-center gap-1 ml-2">
{NAV.map(n => (
{navItems.map(n => (
<NavLink
key={n.to}
to={n.to}
@@ -85,7 +94,7 @@ export function Header() {
/>
</form>
<nav className="flex flex-col gap-1">
{NAV.map(n => (
{navItems.map(n => (
<NavLink
key={n.to}
to={n.to}
+59
View File
@@ -0,0 +1,59 @@
// The client builds CI publishes (ci/downloads/publish.py), served through
// the server's DownloadsController. The manifest keeps the snake_case keys
// publish.py writes.
import { ApiError, get } from './api';
export interface DownloadFile {
id: string; // pc-full | pc-patch | quest | quest-installer
platform: 'pc' | 'quest' | string;
name: string;
path: string;
size: number;
sha256: string;
build?: string;
commit?: string;
built_at?: string;
}
export interface DownloadRelease {
id: string;
title: string;
game_version?: string;
pc_exe?: string;
files: DownloadFile[];
}
export interface DownloadsManifest {
releases: DownloadRelease[];
updated?: string;
}
let cached: Promise<DownloadsManifest | null> | null = null;
/** The manifest, or null when this server doesn't offer downloads. Fetched
* once per page load; pass `fresh` to re-read it. */
export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
if (!cached || fresh) {
cached = get<DownloadsManifest>('/downloads').catch((e) => {
if (e instanceof ApiError && e.status === 404) return null;
cached = null;
throw e;
});
}
return cached;
}
export const fileUrl = (f: DownloadFile) =>
`/api/site/v1/downloads/files/${f.path.split('/').map(encodeURIComponent).join('/')}`;
export interface QuestBuild { release: DownloadRelease; file: DownloadFile }
/** A published Quest package: the one at `path` when given, otherwise the
* newest release's. Null when there's no match. */
export function questBuild(m: DownloadsManifest | null, path?: string | null): QuestBuild | null {
for (const release of m?.releases ?? []) {
const file = release.files.find((f) => f.id === 'quest' && (!path || f.path === path));
if (file) return { release, file };
}
return null;
}
+2 -2
View File
@@ -95,7 +95,7 @@ export async function installXapk(
let done = 0;
const total = xapk.apk.size;
onProgress({ phase: 'app', label: 'Installing the app', done, total });
const stream = counted(await entryStream(xapk.file, xapk.apk), (n) => {
const stream = counted(await entryStream(xapk.source, xapk.apk), (n) => {
done += n;
onProgress({ phase: 'app', label: 'Installing the app', done, total });
});
@@ -124,7 +124,7 @@ export async function installXapk(
let done = 0;
onProgress({ phase: 'data', label: 'Copying game data', done, total });
const stream = counted(await entryStream(xapk.file, entry), (n) => {
const stream = counted(await entryStream(xapk.source, entry), (n) => {
done += n;
onProgress({ phase: 'data', label: 'Copying game data', done, total });
});
+67 -17
View File
@@ -1,9 +1,59 @@
// Read a DorkNet .xapk (zip) straight from a File the player picked, without
// loading it into memory. DorkNet.Quest/scripts/package.ps1 stores the APK and
// the OBB uncompressed, so those entries are plain byte ranges of the file and
// stream out with Blob.slice(); manifest.json is deflated and goes through
// Read a DorkNet .xapk (zip) without loading it into memory, either from a
// File the player picked or from a URL that answers HTTP Range requests (the
// server's latest published build). DorkNet.Quest/scripts/build.py stores the
// APK and the OBB uncompressed, so those entries are plain byte ranges that
// stream straight through; manifest.json is deflated and goes through
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
/** Random access to the package's bytes; `end` is exclusive. */
export interface ByteSource {
name: string;
size: number;
read(start: number, end: number): Promise<ArrayBuffer>;
stream(start: number, end: number): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>;
}
export function fileSource(file: File): ByteSource {
return {
name: file.name,
size: file.size,
read: (start, end) => file.slice(start, end).arrayBuffer(),
stream: async (start, end) => file.slice(start, end).stream(),
};
}
/** A package on the server. `size` comes from the downloads manifest; every
* read is one Range request. */
export function urlSource(url: string, name: string, size: number): ByteSource {
const get = async (start: number, end: number): Promise<Response> => {
let res: Response;
try {
res = await fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` }, cache: 'no-store' });
} catch {
// A cross-origin redirect (an expired sign-in at an access proxy) also lands here.
throw new Error("Couldn't reach the server. Reload the page (you may need to sign in again) and try again.");
}
if (res.status !== 206) {
throw new Error(res.status === 200
? "The server doesn't support partial downloads, so the package can't be read in place."
: `Downloading the package failed (HTTP ${res.status}).`);
}
return res;
};
const empty = () => new ReadableStream<Uint8Array<ArrayBuffer>>({ start: (c) => c.close() });
return {
name,
size,
read: async (start, end) => (end <= start ? new ArrayBuffer(0) : (await get(start, end)).arrayBuffer()),
stream: async (start, end) => {
if (end <= start) return empty();
const body = (await get(start, end)).body;
if (!body) throw new Error('The server sent an empty response.');
return body;
},
};
}
export interface XapkEntry {
name: string;
method: number; // 0 = stored, 8 = deflate
@@ -26,7 +76,7 @@ export interface XapkManifest {
}
export interface Xapk {
file: File;
source: ByteSource;
manifest: XapkManifest;
entries: Map<string, XapkEntry>;
apk: XapkEntry;
@@ -39,18 +89,18 @@ const ZIP64_EOCD = 0x06064b50;
const CENTRAL = 0x02014b50;
const LOCAL = 0x04034b50;
async function bytes(file: Blob, start: number, end: number): Promise<DataView> {
return new DataView(await file.slice(start, end).arrayBuffer());
async function bytes(src: ByteSource, start: number, end: number): Promise<DataView> {
return new DataView(await src.read(start, end));
}
function u64(v: DataView, at: number): number {
return Number(v.getBigUint64(at, true));
}
export async function openXapk(file: File): Promise<Xapk> {
export async function openXapk(src: ByteSource): Promise<Xapk> {
// End of central directory: last 22 bytes plus up to 64 KiB of comment.
const tailStart = Math.max(0, file.size - (22 + 0xffff + 20));
const tail = await bytes(file, tailStart, file.size);
const tailStart = Math.max(0, src.size - (22 + 0xffff + 20));
const tail = await bytes(src, tailStart, src.size);
let eocd = -1;
for (let i = tail.byteLength - 22; i >= 0; i--) {
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
@@ -65,14 +115,14 @@ export async function openXapk(file: File): Promise<Xapk> {
const loc = eocd - 20;
if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.');
const z64At = u64(tail, loc + 8);
const z64 = await bytes(file, z64At, z64At + 56);
const z64 = await bytes(src, z64At, z64At + 56);
if (z64.getUint32(0, true) !== ZIP64_EOCD) throw new Error('Unsupported zip64 layout.');
count = u64(z64, 32);
cdSize = u64(z64, 40);
cdOffset = u64(z64, 48);
}
const cd = await bytes(file, cdOffset, cdOffset + cdSize);
const cd = await bytes(src, cdOffset, cdOffset + cdSize);
const entries = new Map<string, XapkEntry>();
const decoder = new TextDecoder();
let p = 0;
@@ -109,7 +159,7 @@ export async function openXapk(file: File): Promise<Xapk> {
const manifestEntry = entries.get('manifest.json');
if (!manifestEntry) throw new Error('Not an .xapk: manifest.json is missing.');
const manifest = JSON.parse(await new Response(await entryStream(file, manifestEntry)).text()) as XapkManifest;
const manifest = JSON.parse(await new Response(await entryStream(src, manifestEntry)).text()) as XapkManifest;
if (!manifest.package_name) throw new Error('manifest.json has no package_name.');
const apk = entries.get(`${manifest.package_name}.apk`)
@@ -122,15 +172,15 @@ export async function openXapk(file: File): Promise<Xapk> {
return { entry, installPath: x.install_path };
});
return { file, manifest, entries, apk, expansions };
return { source: src, manifest, entries, apk, expansions };
}
/** The entry's uncompressed bytes as a stream. */
export async function entryStream(file: File, entry: XapkEntry): Promise<ReadableStream<Uint8Array>> {
const local = await bytes(file, entry.localHeaderOffset, entry.localHeaderOffset + 30);
export async function entryStream(src: ByteSource, entry: XapkEntry): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
const local = await bytes(src, entry.localHeaderOffset, entry.localHeaderOffset + 30);
if (local.getUint32(0, true) !== LOCAL) throw new Error(`Corrupt entry ${entry.name}.`);
const start = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
const raw = file.slice(start, start + entry.compressedSize).stream();
const raw = await src.stream(start, start + entry.compressedSize);
if (entry.method === 0) return raw;
if (entry.method === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw'));
throw new Error(`Unsupported compression in ${entry.name}.`);
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { fileUrl, getDownloads, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
const fmtSize = (n: number) =>
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
const fmtDate = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(+d) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
};
const PLATFORMS = [
{ id: 'pc', title: 'PC', hint: 'Windows, with or without a VR headset.' },
{ id: 'quest', title: 'Meta Quest', hint: 'Needs developer mode on your headset and a USB cable.' },
];
// What each file is for, in players' terms. Keyed by the ids publish.py writes.
function describe(f: DownloadFile, rel: DownloadRelease): { title: string; primary?: boolean; body: React.ReactNode } {
switch (f.id) {
case 'pc-full':
return {
title: 'Full game', primary: true,
body: <>Rec Room {rel.title} with DorkNet already set up. Unzip it anywhere and
run {rel.pc_exe ? <code className="text-ink-200">{rel.pc_exe}</code> : 'the game'}.</>,
};
case 'pc-patch':
return {
title: 'Patch only',
body: <>Just the DorkNet part (MelonLoader and the DorkNet mod), for a copy of the game you already have. The
DorkNet launcher installs this for you; skip it if you got the full game.</>,
};
case 'quest':
return {
title: 'Quest app', primary: true,
body: <>Install it straight from this site: plug in your headset and use the <Link className="text-brand-300 hover:text-brand-200" to="/quest">Quest
page</Link> (Chrome or Edge). Or download it for SideQuest.</>,
};
case 'quest-installer':
return {
title: 'Install scripts',
body: <>Prefer the command line? Unzip these next to the Quest app and run <code className="text-ink-200">install.cmd</code> (Windows)
or <code className="text-ink-200">install.sh</code> (macOS / Linux). Needs adb.</>,
};
default:
return { title: f.name, body: null };
}
}
function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
const d = describe(f, rel);
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(f.sha256);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* clipboard blocked: the full hash is in the tooltip */ }
};
return (
<article className={`card !p-5 flex flex-col gap-3 ${d.primary ? 'border-brand-500/50 ring-2 ring-brand-500/15' : ''}`}>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-ink-50">{d.title}</h3>
{d.primary && <span className="badge bg-brand-500/15 text-brand-200">Start here</span>}
</div>
<p className="text-sm text-ink-300">{d.body}</p>
<div className="font-mono text-xs text-ink-400 break-all">{f.name}</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-ink-400">
<span>{fmtSize(f.size)}</span>
{f.built_at && <span>Updated {fmtDate(f.built_at)}</span>}
{f.build && <span>Build {f.build}</span>}
</div>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
{f.id === 'quest' ? (
<>
<Link className="btn-primary" to={`/quest?file=${encodeURIComponent(f.path)}`}>Install on Quest</Link>
<a className="btn-secondary" href={fileUrl(f)} download={f.name}>Download</a>
</>
) : (
<a className={d.primary ? 'btn-primary' : 'btn-secondary'} href={fileUrl(f)} download={f.name}>Download</a>
)}
<button
type="button"
onClick={copy}
title={`Copy SHA-256: ${f.sha256}`}
className={`rounded-md border border-dashed px-2 py-1 font-mono text-xs transition-colors ${
copied ? 'border-success text-success' : 'border-ink-700 text-ink-400 hover:text-ink-100'}`}
>
{copied ? 'Copied' : `SHA-256 ${f.sha256.slice(0, 12)}…`}
</button>
</div>
</article>
);
}
export function Downloads() {
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
getDownloads(true).then(setM).catch((e) => setErr((e as Error).message));
}, []);
const releases = (m?.releases ?? []).filter((r) => r.files.length);
return (
<div className="max-w-4xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Downloads</h1>
<p className="text-sm text-ink-400">
Rec Room for DorkNet, built from the DorkNet source by the build server. The SHA-256 next to each file
lets you check your download.
</p>
</div>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
{m === undefined && !err && <p className="text-sm text-ink-400">Loading builds…</p>}
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
{releases.map((rel) => (
<section key={rel.id} className="space-y-5">
<div className="flex flex-wrap items-baseline gap-x-3">
<h2 className="text-lg font-semibold text-ink-50">Rec Room {rel.title}</h2>
{rel.game_version && <span className="text-xs text-ink-400">Game build of {fmtDate(`${rel.game_version}T12:00:00Z`)}</span>}
</div>
{PLATFORMS.map((p) => {
const files = rel.files.filter((f) => f.platform === p.id);
if (!files.length) return null;
return (
<div key={p.id} className="space-y-2">
<div>
<h3 className="label">{p.title}</h3>
<p className="text-xs text-ink-400">{p.hint}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{files.map((f) => <FileCard key={f.id} f={f} rel={rel} />)}
</div>
</div>
);
})}
</section>
))}
{releases.length > 0 && (
<p className="border-t border-ink-800 pt-4 text-xs text-ink-400">
These builds contain Rec Room's own game files. They're for this community only: please don't re-share them.
</p>
)}
</div>
);
}
+50 -14
View File
@@ -1,6 +1,8 @@
import { useRef, useState } from 'react';
import { openXapk, type Xapk } from '../lib/xapk';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
import { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads';
const fmtSize = (n: number) =>
n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`;
@@ -43,15 +45,36 @@ export function Quest() {
const [err, setErr] = useState<string | null>(null);
const [done, setDone] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
// The server's latest published Quest build, if it offers downloads.
const [latest, setLatest] = useState<QuestBuild | null>(null);
const [params] = useSearchParams();
const pickFile = async (f: File | undefined) => {
if (!f) return;
const open = async (load: () => Promise<Xapk>) => {
setErr(null); setDone(false); setProgress({}); setBusy('file');
try { setXapk(await openXapk(f)); }
try { setXapk(await load()); }
catch (e) { setXapk(null); setErr((e as Error).message); }
finally { setBusy(null); }
};
const pickFile = async (f: File | undefined) => {
if (f) await open(() => openXapk(fileSource(f)));
};
const pickBuild = async (b: QuestBuild | null) => {
if (b) await open(() => openXapk(urlSource(fileUrl(b.file), b.file.name, b.file.size)));
};
useEffect(() => {
getDownloads().then((m) => {
setLatest(questBuild(m));
// Arriving from a Quest card's "Install on Quest" on the Downloads page:
// that card's own file, which may not be the newest release's.
const wanted = params.get('file');
if (wanted) void pickBuild(questBuild(m, wanted));
}).catch(() => { /* no downloads: only the file picker */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const connect = async () => {
setErr(null); setBusy('connect');
try { setHeadset(await connectHeadset()); }
@@ -108,19 +131,32 @@ export function Quest() {
</div>
)}
<Step n={1} title="Choose the .xapk file" done={!!xapk}>
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
<Step n={1} title={latest ? 'Pick the build' : 'Choose the .xapk file'} done={!!xapk}>
{latest ? (
<p className="text-sm text-ink-400">
Install the latest build straight from this server: nothing to download first, it streams to the
headset during the install. Or pick a <code className="text-ink-200">.xapk</code> you already have.
</p>
) : (
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
)}
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
<div className="flex flex-wrap items-center gap-3">
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
{busy === 'file' ? 'Reading…' : xapk ? 'Choose a different file' : 'Choose file'}
</button>
{xapk && (
<span className="text-sm text-ink-300">
<span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
</span>
{latest && (
<button className="btn-primary" disabled={busy !== null} onClick={() => pickBuild(latest)}>
{busy === 'file' ? 'Reading…' : `Latest build: ${latest.release.title}${latest.file.build ? ` (build ${latest.file.build})` : ''}`}
</button>
)}
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
{busy === 'file' && !latest ? 'Reading…' : xapk && !latest ? 'Choose a different file' : 'Choose file'}
</button>
</div>
{xapk && (
<p className="text-sm text-ink-300">
Selected: <span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
<span className="text-ink-500"> · {xapk.source.name}</span>
</p>
)}
</Step>
<Step n={2} title="Connect your headset" done={!!headset}>
Vendored
+240
View File
@@ -0,0 +1,240 @@
// DorkNet client builds — PC (patcher zip + full client) and Quest (.xapk).
//
// Runs on the "dorknet-builder" agent from ci/jenkins (an inbound agent with
// Python + LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone).
//
// Stock builds come out of the restic build archive on the FTP. Where it lives
// is server configuration, not part of the repo: the FTP host, login and the
// restic password are Jenkins credentials, and the repository folders come
// from the agent's environment (ARCHIVE_PC_REPO / ARCHIVE_QUEST_REPO), each
// overridable per run. A build is picked by snapshot tags.
//
// On a push, only the platforms whose code changed are rebuilt. A manual run
// picks the platforms with PLATFORMS. See ci/jenkins/README.md.
//
// Finished builds are renamed for players and published to the downloads bucket
// (ci/downloads) unless PUBLISH_DOWNLOADS is off. The build scripts keep their
// own file names, which the launcher depends on.
// The builds this branch targets. Any other archived build can be picked per
// run with the *_SNAPSHOT_TAGS parameters.
def PC_VERSION_KEY = 'march_2023_03_21'
def PC_DEFAULT_TAGS = 'manifest=7490748483298966814' // Steam 2023-03-21 (appVersion 20230317)
def QUEST_DEFAULT_TAGS = 'version-code=2128210' // Quest 20230317
// How this branch's builds are listed on the site's Downloads page.
def RELEASE_ID = '2023.03'
def RELEASE_TITLE = 'March 2023'
def RELEASE_GAME_VERSION = '2023-03-21'
def buildPc = false
def buildQuest = false
pipeline {
agent { label 'dorknet-builder' }
options {
timestamps()
ansiColor('xterm')
disableConcurrentBuilds()
// Full clients and xapks are several GB each: keep artifacts for the
// last few runs only.
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '3'))
timeout(time: 3, unit: 'HOURS')
}
parameters {
choice(name: 'PLATFORMS', choices: ['both', 'pc', 'quest'],
description: 'What to build on a manual run. Push builds rebuild whatever changed.')
booleanParam(name: 'PC_FULL_CLIENT', defaultValue: true,
description: 'Also build the full PC client (stock build + MelonLoader + mod + config). The patcher zip is always built.')
string(name: 'PC_SNAPSHOT_TAGS', defaultValue: '',
description: "restic tags picking the stock PC build, comma = AND. Empty: ${PC_DEFAULT_TAGS}")
string(name: 'QUEST_SNAPSHOT_TAGS', defaultValue: '',
description: "restic tags picking the stock Quest build, comma = AND. Empty: ${QUEST_DEFAULT_TAGS}")
string(name: 'PC_REPO', defaultValue: '',
description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO set on the agent.')
string(name: 'QUEST_REPO', defaultValue: '',
description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO set on the agent.')
booleanParam(name: 'LIST_SNAPSHOTS', defaultValue: false,
description: 'Only list the snapshots matching the tags in both repositories (to find a build), then stop.')
booleanParam(name: 'PUBLISH_DOWNLOADS', defaultValue: true,
description: 'Publish what this run built to the downloads bucket (listed on the site Downloads page). Push builds publish too.')
}
stages {
stage('Plan') {
steps {
script {
def pushed = !currentBuild.getBuildCauses('com.cloudbees.jenkins.GitHubPushCause').isEmpty() ||
!currentBuild.getBuildCauses('hudson.triggers.SCMTrigger$SCMTriggerCause').isEmpty()
if (params.LIST_SNAPSHOTS) {
buildPc = false
buildQuest = false
} else if (pushed) {
def changed = []
for (cs in currentBuild.changeSets) {
for (entry in cs.items) changed.addAll(entry.affectedPaths)
}
def touches = { prefixes -> changed.any { p -> prefixes.any { p.startsWith(it) } } }
def common = ['Jenkinsfile', 'ci/jenkins/']
buildPc = touches(common + ['DorkNet.ClientMod/', 'tools/build_pc.py'])
buildQuest = touches(common + ['DorkNet.Quest/'])
echo "Push build; ${changed.size()} changed file(s)"
} else {
buildPc = params.PLATFORMS in ['both', 'pc']
buildQuest = params.PLATFORMS in ['both', 'quest']
}
echo "Build PC: ${buildPc}, Quest: ${buildQuest}"
}
}
}
stage('List snapshots') {
when { expression { params.LIST_SNAPSHOTS } }
steps {
withArchive {
withEnv(["PC_REPO_OVERRIDE=${params.PC_REPO}", "QUEST_REPO_OVERRIDE=${params.QUEST_REPO}",
"PC_TAGS=${params.PC_SNAPSHOT_TAGS}", "QUEST_TAGS=${params.QUEST_SNAPSHOT_TAGS}"]) {
sh '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
pc="${PC_REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}"
quest="${QUEST_REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}"
echo "== PC: $pc"; archive-restore list "$pc" "$PC_TAGS"
echo "== Quest: $quest"; archive-restore list "$quest" "$QUEST_TAGS"
'''
}
}
}
}
stage('PC patcher zip') {
when { expression { buildPc } }
steps {
sh "python3 tools/build_pc.py patcher --version-key ${PC_VERSION_KEY} --output-dir out/pc"
}
}
stage('PC full client') {
when { expression { buildPc && params.PC_FULL_CLIENT } }
steps {
script {
def base = withArchive {
withEnv(["REPO_OVERRIDE=${params.PC_REPO}", "TAGS=${params.PC_SNAPSHOT_TAGS ?: PC_DEFAULT_TAGS}"]) {
sh(returnStdout: true, script: '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}"
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/pc" --exclude /.DepotDownloader
''').trim().readLines().last()
}
}
withCredentials([file(credentialsId: 'clientmod-config', variable: 'CLIENTMOD_CONFIG')]) {
withEnv(["BASE_CLIENT=${base}"]) {
sh """
python3 tools/build_pc.py client \\
--base-client "\$BASE_CLIENT" \\
--patcher-zip out/pc/dorknet-clientpatch-${PC_VERSION_KEY}.zip \\
--config "\$CLIENTMOD_CONFIG" \\
--output-zip out/pc/dorknet-client-${PC_VERSION_KEY}.zip
"""
}
}
}
}
}
stage('Quest') {
when { expression { buildQuest } }
steps {
script {
def stock = withArchive {
withEnv(["REPO_OVERRIDE=${params.QUEST_REPO}", "TAGS=${params.QUEST_SNAPSHOT_TAGS ?: QUEST_DEFAULT_TAGS}"]) {
sh(returnStdout: true, script: '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}"
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/quest"
''').trim().readLines().last()
}
}
withCredentials([
file(credentialsId: 'quest-keystore', variable: 'QUEST_KEYSTORE'),
string(credentialsId: 'quest-keystore-password', variable: 'QUEST_KEYSTORE_PASSWORD'),
file(credentialsId: 'quest-config', variable: 'QUEST_CONFIG'),
]) {
withEnv(["QUEST_STOCK=${stock}"]) {
// The hook table for the build's versionCode must already be
// resolved and committed under DorkNet.Quest/targets/; build.py
// checks it against the APK's libil2cpp.so.
sh '''
set -eu
obb="$(ls "$QUEST_STOCK"/obbs/main.*.obb | head -n1)"
code="$(basename "$obb" | cut -d. -f2)"
table="$(ls DorkNet.Quest/targets/*-"$code".json 2>/dev/null | head -n1 || true)"
if [ -z "$table" ]; then
echo "No resolved hook table for versionCode $code in DorkNet.Quest/targets/." >&2
echo "Resolve one locally with build.py build --dump-cs and commit it." >&2
exit 1
fi
mkdir -p out/quest
python3 DorkNet.Quest/scripts/build.py build \
--apk "$QUEST_STOCK/app.apk" --obb "$obb" \
--targets-json "$table" \
--config "$QUEST_CONFIG" \
--keystore "$QUEST_KEYSTORE" --keystore-pass "$QUEST_KEYSTORE_PASSWORD" \
--work-dir "$WORKSPACE/quest-work" \
--out-apk "$WORKSPACE/out/quest/dorknet-recroom-$code.apk"
'''
}
}
}
}
}
stage('Downloads') {
when { expression { buildPc || buildQuest } }
steps {
sh """
python3 ci/downloads/publish.py stage --out out --release ${RELEASE_ID} \\
--title '${RELEASE_TITLE}' --game-version ${RELEASE_GAME_VERSION} --version-key ${PC_VERSION_KEY}
"""
script {
if (params.PUBLISH_DOWNLOADS) {
// The bucket's S3 endpoint and name come from the agent's
// environment (DOWNLOADS_S3_ENDPOINT / DOWNLOADS_BUCKET).
withCredentials([usernamePassword(credentialsId: 'downloads-s3',
usernameVariable: 'DOWNLOADS_S3_KEY_ID', passwordVariable: 'DOWNLOADS_S3_SECRET')]) {
sh 'python3 ci/downloads/publish.py upload --staged out/downloads'
}
} else {
echo 'PUBLISH_DOWNLOADS is off: the files are only archived.'
}
}
}
}
}
post {
success {
// The renamed, player-facing files (and release.json): the same set the
// downloads page offers.
archiveArtifacts artifacts: 'out/downloads/**', allowEmptyArchive: true, fingerprint: true
}
cleanup {
// Runs after archiving. Keeps the restic cache and restored stock
// builds ($DORKNET_CACHE on the agent, outside the workspace); drops
// the workspace's build output and scratch.
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
// FTP + restic credentials for archive-restore.
def withArchive(Closure body) {
withCredentials([
usernamePassword(credentialsId: 'archive-ftp', usernameVariable: 'ARCHIVE_FTP_USER', passwordVariable: 'ARCHIVE_FTP_PASSWORD'),
string(credentialsId: 'archive-ftp-host', variable: 'ARCHIVE_FTP_HOST'),
string(credentialsId: 'archive-restic-password', variable: 'RESTIC_PASSWORD'),
]) {
return body()
}
}
+16 -4
View File
@@ -38,10 +38,12 @@ What the 2023 client gets here that the 2020 branches don't have:
`DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one
regresses. Routes, verbs and wire shapes are in
[docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md).
- **Realtime over the SignalR hub** (`hub/v1` on the notify host): numeric
push ids, presence updates to friends that honour each player's
online-status visibility, and account updates when someone edits their
profile.
- **Realtime over the SignalR hub** (`hub/v1` on the notify host): every
push matches the client's own reader — friends, messages, kicks and
logouts, gifts, balances, consumables, player events, level-ups and
account updates — and presence reaches both friends and any player who
subscribed to you, honouring your online-status visibility:
[docs/recroom-2023-notifications.md](docs/recroom-2023-notifications.md).
- **Room saves and sub-rooms** through the storage host, plus cloning and
private instances:
[docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md).
@@ -214,6 +216,16 @@ keep the prefab value) trade per-frame size for bandwidth if a high FPS
saturates the link. Only the player *sharing* needs the override. Default
`0` (off).
### Sign-in page instead of a new account
When the title screen finds no remembered account for the machine, Rec Room
creates a brand-new account automatically. On a server with sign-ups off that
is refused and the title screen keeps retrying. By default the mod opens the
**sign-in page** instead, so players log into their existing DorkNet account.
Set `"AutoCreateAccount": true` in `dorknet-clientmod.json` to keep Rec Room's
behaviour on a server that allows sign-ups. The Quest build has the same
option (`AutoCreateAccount` in `dorknet-quest.cfg`).
### RRO quest team size (opt-in)
RRO quests ship with a baked 4-player `GameConfigurationAsset` team cap.
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""Publish DorkNet client builds to the downloads bucket.
stage the build output in out/ -> out/downloads/<release>/ under the
names players see, plus release.json describing those files
upload that folder -> the downloads bucket, then merge release.json into
the bucket's manifest.json
The DorkNet server reads the bucket and offers the files on its site (the
Downloads page, and Latest build on the Quest page); see docs/deploy.md.
The build scripts keep their own file names on purpose: the launcher fetches
dorknet-clientpatch-<VersionKey>.zip from GitHub Releases by that exact name.
Only the published copies are renamed:
dorknet-client-<key>.zip DorkNet-<release>-PC-Full.zip
dorknet-clientpatch-<key>.zip DorkNet-<release>-PC-Patch.zip
DorkNet-Quest-<ver>/DorkNet-<ver>.xapk DorkNet-<release>-Quest.xapk
DorkNet-Quest-<ver>/install.*, README DorkNet-<release>-Quest-Installer.zip
The server reads the bucket over plain HTTP on the internal network (e.g.
Garage's web endpoint); nothing needs to be public. upload talks to its S3
API with rclone; the endpoint, bucket and key come from the environment:
DOWNLOADS_S3_ENDPOINT S3 API URL, e.g. http://garage-<uuid>:3900
DOWNLOADS_BUCKET bucket name, e.g. dorknet-downloads
DOWNLOADS_S3_REGION optional, default garage
DOWNLOADS_S3_KEY_ID / DOWNLOADS_S3_SECRET
python3 ci/downloads/publish.py stage --out out --release 2023.03 \\
--title "March 2023" --game-version 2023-03-21 --version-key march_2023_03_21
python3 ci/downloads/publish.py upload --staged out/downloads
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import os
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
from typing import NoReturn
NO_CACHE = "Cache-Control: no-cache"
INSTALLER_NOTE = """Unzip these files into the same folder as {xapk}, then
run install.cmd (Windows) or ./install.sh (macOS / Linux).
"""
def fail(msg: str) -> NoReturn:
print(f"error: {msg}", file=sys.stderr)
sys.exit(1)
def step(msg: str) -> None:
print(f"==> {msg}", flush=True)
def human(n: int) -> str:
return f"{n / 2**20:,.1f} MB" if n >= 2**20 else f"{max(1, round(n / 2**10))} KB"
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
while chunk := f.read(1 << 20):
h.update(chunk)
return h.hexdigest()
def link_or_copy(src: Path, dst: Path) -> None:
"""Hard-link where possible: the full client is several GB."""
dst.unlink(missing_ok=True)
try:
os.link(src, dst)
except OSError:
shutil.copy2(src, dst)
def game_exe(full_zip: Path) -> str | None:
"""The game's exe: the one with a matching <name>_Data folder at the root."""
with zipfile.ZipFile(full_zip) as z:
names = z.namelist()
tops = {n.split("/", 1)[0] for n in names}
exes = sorted(t for t in tops if t.lower().endswith(".exe") and f"{t[:-4]}_Data" in tops)
return exes[0] if exes else None
# ---- stage ----------------------------------------------------------------
def cmd_stage(a: argparse.Namespace) -> None:
out = Path(a.out)
dest = Path(a.dest) if a.dest else out / "downloads"
rel_dir = dest / a.release
if rel_dir.exists():
shutil.rmtree(rel_dir)
rel_dir.mkdir(parents=True)
prefix = f"DorkNet-{a.release}"
staged: list[tuple[str, str, Path]] = [] # (id, platform, staged file)
extra: dict[str, str] = {}
full = out / "pc" / f"dorknet-client-{a.version_key}.zip"
patch = out / "pc" / f"dorknet-clientpatch-{a.version_key}.zip"
if full.is_file():
dst = rel_dir / f"{prefix}-PC-Full.zip"
link_or_copy(full, dst)
staged.append(("pc-full", "pc", dst))
exe = game_exe(full)
if exe:
extra["pc_exe"] = exe
if patch.is_file():
dst = rel_dir / f"{prefix}-PC-Patch.zip"
link_or_copy(patch, dst)
staged.append(("pc-patch", "pc", dst))
packages = sorted((out / "quest").glob("DorkNet-Quest-*/"))
if len(packages) > 1:
fail(f"more than one Quest package folder in {out / 'quest'}: {', '.join(p.name for p in packages)}")
if packages:
pkg = packages[0]
xapks = sorted(pkg.glob("*.xapk"))
if len(xapks) != 1:
fail(f"expected one .xapk in {pkg}, found {len(xapks)}")
xapk = rel_dir / f"{prefix}-Quest.xapk"
link_or_copy(xapks[0], xapk)
staged.append(("quest", "quest", xapk))
installer = rel_dir / f"{prefix}-Quest-Installer.zip"
with zipfile.ZipFile(installer, "w", zipfile.ZIP_DEFLATED) as z:
for name in ("install.cmd", "install.ps1", "install.sh"):
src = pkg / name
if not src.is_file():
fail(f"{src} missing")
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
info.external_attr = (0o755 if name.endswith(".sh") else 0o644) << 16
info.compress_type = zipfile.ZIP_DEFLATED
z.writestr(info, src.read_bytes())
readme = pkg / "README.txt"
text = INSTALLER_NOTE.format(xapk=xapk.name) + (readme.read_text(encoding="utf-8") if readme.is_file() else "")
info = zipfile.ZipInfo("README.txt", date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
z.writestr(info, text)
staged.append(("quest-installer", "quest", installer))
if not staged:
fail(f"nothing to publish in {out}")
built_at = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()
files = []
for fid, platform, path in staged:
step(f"{path.name}: hashing")
files.append({
"id": fid,
"platform": platform,
"name": path.name,
"path": f"{a.release}/{path.name}",
"size": path.stat().st_size,
"sha256": sha256(path),
"build": a.build,
"commit": a.commit,
"built_at": built_at,
})
release = {"id": a.release, "title": a.title, "game_version": a.game_version, **extra, "files": files}
(dest / "release.json").write_text(json.dumps(release, indent=2) + "\n", encoding="utf-8")
print()
for f in files:
print(f" {f['name']} {human(f['size'])} {f['sha256'][:16]}…")
print(f"Staged {len(files)} file(s) in {rel_dir}")
# ---- upload ---------------------------------------------------------------
def rclone_env() -> dict[str, str]:
env = dict(os.environ)
for k in ("DOWNLOADS_S3_ENDPOINT", "DOWNLOADS_BUCKET", "DOWNLOADS_S3_KEY_ID", "DOWNLOADS_S3_SECRET"):
if not env.get(k):
fail(f"{k} is not set")
env.update({
"RCLONE_CONFIG_DL_TYPE": "s3",
"RCLONE_CONFIG_DL_PROVIDER": "Other",
"RCLONE_CONFIG_DL_ENDPOINT": env["DOWNLOADS_S3_ENDPOINT"],
"RCLONE_CONFIG_DL_REGION": env.get("DOWNLOADS_S3_REGION") or "garage",
"RCLONE_CONFIG_DL_ACCESS_KEY_ID": env["DOWNLOADS_S3_KEY_ID"],
"RCLONE_CONFIG_DL_SECRET_ACCESS_KEY": env["DOWNLOADS_S3_SECRET"],
"RCLONE_CONFIG_DL_FORCE_PATH_STYLE": "true",
"RCLONE_CONFIG_DL_NO_CHECK_BUCKET": "true",
})
return env
def rclone(env: dict[str, str], *args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
r = subprocess.run(["rclone", *args], env=env, input=stdin, capture_output=True, text=True)
if check and r.returncode != 0:
fail(f"rclone {args[0]} failed ({r.returncode}): {r.stderr.strip()}")
return r
def merge(manifest: dict, release: dict) -> dict:
"""Replace this release's files that were just built; keep the others
(a push build may rebuild only one platform)."""
releases = [r for r in manifest.get("releases", []) if r.get("id") != release["id"]]
old = next((r for r in manifest.get("releases", []) if r.get("id") == release["id"]), {})
new_ids = {f["id"] for f in release["files"]}
kept = [f for f in old.get("files", []) if f.get("id") not in new_ids]
merged = {**old, **release, "files": kept + release["files"]}
if "pc_exe" not in release and "pc_exe" in old:
merged["pc_exe"] = old["pc_exe"]
order = ["pc-full", "pc-patch", "quest", "quest-installer"]
merged["files"].sort(key=lambda f: order.index(f["id"]) if f["id"] in order else len(order))
releases.append(merged)
releases.sort(key=lambda r: r["id"], reverse=True)
out = {**manifest, "releases": releases,
"updated": datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()}
return out
def cmd_upload(a: argparse.Namespace) -> None:
staged = Path(a.staged)
release = json.loads((staged / "release.json").read_text(encoding="utf-8"))
env = rclone_env()
bucket = f"dl:{env['DOWNLOADS_BUCKET']}"
rel = release["id"]
# 1. the files, before the manifest that points at them
for f in release["files"]:
step(f"uploading {f['name']} ({human(f['size'])})")
rclone(env, "copyto", str(staged / f["path"]), f"{bucket}/{f['path']}",
"--header-upload", NO_CACHE, "--s3-upload-concurrency", "8", "--s3-chunk-size", "64M")
# 2. merge into the live manifest
r = rclone(env, "cat", f"{bucket}/manifest.json", check=False)
manifest = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {}
manifest = merge(manifest, release)
# 3. the manifest, last: it is what makes the new files visible
step("uploading manifest.json")
rclone(env, "rcat", f"{bucket}/manifest.json", "--header-upload", NO_CACHE,
"--header-upload", "Content-Type: application/json", stdin=json.dumps(manifest, indent=2) + "\n")
# 4. drop files of this release the manifest no longer lists (renames)
listed = {f["path"] for r in manifest["releases"] if r["id"] == rel for f in r["files"]}
r = rclone(env, "lsf", f"{bucket}/{rel}/", "--files-only", check=False)
for name in r.stdout.split():
if f"{rel}/{name}" not in listed:
step(f"removing stale {rel}/{name}")
rclone(env, "deletefile", f"{bucket}/{rel}/{name}")
print(f"Published {len(release['files'])} file(s) for {release['title']}.")
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("stage", help="out/ -> out/downloads/<release>/ + release.json")
s.add_argument("--out", default="out", help="the build output folder (default: out)")
s.add_argument("--dest", help="where to stage (default: <out>/downloads)")
s.add_argument("--release", required=True, help="release id used in file names and paths, e.g. 2023.03")
s.add_argument("--title", required=True, help='shown on the Downloads page, e.g. "March 2023"')
s.add_argument("--game-version", required=True, help="the stock build's date, e.g. 2023-03-21")
s.add_argument("--version-key", required=True, help="the PC build's version key, e.g. march_2023_03_21")
s.add_argument("--build", default=os.environ.get("BUILD_NUMBER", ""), help="CI build number (default: $BUILD_NUMBER)")
s.add_argument("--commit", default=os.environ.get("GIT_COMMIT", ""), help="source commit (default: $GIT_COMMIT)")
s.set_defaults(fn=cmd_stage)
u = sub.add_parser("upload", help="staged folder -> downloads bucket + manifest.json")
u.add_argument("--staged", default="out/downloads", help="the folder `stage` wrote (default: out/downloads)")
u.set_defaults(fn=cmd_upload)
a = p.parse_args()
a.fn(a)
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts run inside the Linux image; keep them LF on every checkout.
*.sh text eol=lf
+84
View File
@@ -0,0 +1,84 @@
# DorkNet build agent: a Jenkins inbound agent with everything the PC and
# Quest client builds need. It runs next to Jenkins on Coolify, connects to
# the controller over WebSocket, and runs the jobs labelled "dorknet-builder"
# (the Jenkinsfile at the repo root). Jenkins itself stays on the stock image.
#
# PC Python + .NET SDK tools/build_pc.py
# Quest Python/LIEF + JDK + Android NDK, build-tools, apktool
# DorkNet.Quest/scripts/build.py
# Builds restic + rclone stock builds restored from the restic
# archive on the FTP (archive-restore.sh)
#
# Java 25: Jenkins supports it until Sep 2029 (Java 21 only until Sep 2027).
# Keep the agent's Java the same major version as the controller's.
#
# See ci/jenkins/README.md.
ARG AGENT_IMAGE=jenkins/inbound-agent:latest-jdk25
FROM ${AGENT_IMAGE}
USER root
# ---- base tools + .NET SDK (Microsoft's Debian feed for the image's own
# Debian release) -----------------------------------------------------------
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl wget unzip zip bzip2 python3 python3-venv \
&& . /etc/os-release \
&& wget -q "https://packages.microsoft.com/config/debian/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/ms.deb \
&& dpkg -i /tmp/ms.deb && rm /tmp/ms.deb \
&& apt-get update \
&& apt-get install -y --no-install-recommends dotnet-sdk-8.0 \
&& rm -rf /var/lib/apt/lists/*
# ---- Python with LIEF (DT_NEEDED injection into libmain.so) ---------------
# build.py runs its helpers with -I (isolated), which ignores PYTHONPATH and
# user site-packages, so LIEF goes into the interpreter that is first on PATH.
RUN python3 -m venv /opt/py \
&& /opt/py/bin/pip install --no-cache-dir lief==0.15.1
ENV PATH="/opt/py/bin:${PATH}"
# ---- Android SDK: build-tools (zipalign, apksigner, aapt2) + NDK ----------
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ARG CMDLINE_TOOLS=11076708
ARG BUILD_TOOLS=34.0.0
ARG NDK=27.2.12479018
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/clt.zip \
&& unzip -q /tmp/clt.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/clt.zip \
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
&& (yes || true) | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null \
&& ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install \
"build-tools;${BUILD_TOOLS}" "ndk;${NDK}" > /dev/null \
&& chown -R jenkins:jenkins ${ANDROID_SDK_ROOT}
ENV ANDROID_NDK=${ANDROID_SDK_ROOT}/ndk/${NDK}
# ---- apktool ---------------------------------------------------------------
ARG APKTOOL=2.9.3
RUN wget -q https://github.com/iBotPeaches/Apktool/releases/download/v${APKTOOL}/apktool_${APKTOOL}.jar \
-O ${ANDROID_SDK_ROOT}/apktool.jar
ENV APKTOOL_JAR=${ANDROID_SDK_ROOT}/apktool.jar
# ---- restic + rclone (restic reaches the FTP through rclone) --------------
ARG RESTIC=0.18.1
ARG RCLONE=1.68.2
RUN wget -q https://github.com/restic/restic/releases/download/v${RESTIC}/restic_${RESTIC}_linux_amd64.bz2 -O /tmp/restic.bz2 \
&& bunzip2 /tmp/restic.bz2 \
&& install -m 0755 /tmp/restic /usr/local/bin/restic && rm /tmp/restic \
&& wget -q https://downloads.rclone.org/v${RCLONE}/rclone-v${RCLONE}-linux-amd64.zip -O /tmp/rclone.zip \
&& unzip -q /tmp/rclone.zip -d /tmp/rclone \
&& install -m 0755 /tmp/rclone/rclone-v${RCLONE}-linux-amd64/rclone /usr/local/bin/rclone \
&& rm -rf /tmp/rclone /tmp/rclone.zip
COPY archive-restore.sh /usr/local/bin/archive-restore
RUN chmod +x /usr/local/bin/archive-restore
# Restored stock builds and the restic cache. Mount a volume here so each
# multi-GB stock build is downloaded once, not after every restart.
ENV DORKNET_CACHE=/home/jenkins/dorknet-cache
RUN mkdir -p ${DORKNET_CACHE} && chown jenkins:jenkins ${DORKNET_CACHE}
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_NOLOGO=1
USER jenkins
+238
View File
@@ -0,0 +1,238 @@
# Client builds on Jenkins
The `Jenkinsfile` at the repo root builds the DorkNet clients on your existing
Jenkins.
The builds run on a **build agent**, not on Jenkins itself:
- **The agent:** a container built from `ci/jenkins/Dockerfile`, which is the
Jenkins inbound agent plus Python with LIEF, the .NET SDK, the Android NDK
and build-tools, apktool, restic and rclone. It runs on Coolify next to
Jenkins and connects to it over WebSocket on Docker's internal network. The
job runs on it through the `dorknet-builder` label.
- **Jenkins itself:** stays on the stock image and runs no builds.
Each build is published to the **downloads bucket** under a name players can
read, and the same files are kept as Jenkins artifacts
(`out/downloads/2023.03/`). The DorkNet server offers them on its site: the
**Downloads** page, and **Latest build** on the **Quest** page
(`docs/deploy.md` → Client downloads).
| Download | What it is | Built from |
|---|---|---|
| `DorkNet-2023.03-PC-Full.zip` | the stock Steam build with the patcher applied and your config; ready to run | `tools/build_pc.py client` → `dorknet-client-march_2023_03_21.zip` |
| `DorkNet-2023.03-PC-Patch.zip` | MelonLoader, mod, config template, Steam stub, `manifest.json`; no game files | `tools/build_pc.py patcher` → `dorknet-clientpatch-march_2023_03_21.zip` |
| `DorkNet-2023.03-Quest.xapk` | patched APK + OBB, for the browser installer or SideQuest | `DorkNet.Quest/scripts/build.py build` → `DorkNet-Quest-<ver>/DorkNet-<ver>.xapk` |
| `DorkNet-2023.03-Quest-Installer.zip` | `install.cmd` / `install.ps1` / `install.sh` + README, unzipped next to the `.xapk` | the same package folder |
The build scripts keep their own names: the launcher downloads
`dorknet-clientpatch-<VersionKey>.zip` from GitHub Releases by that exact
name. `ci/downloads/publish.py` renames only the published copies.
The full PC client and the Quest packages contain game files. Keep the bucket
internal and the site behind an access proxy; don't publish them anywhere
public.
## Where the stock builds come from
The stock builds live in restic repositories on the archive FTP. They are the
same repositories as the local archive's `v2/steam-v2` and `v2/quest-v2`. restic
reaches the FTP through its rclone backend (`ci/jenkins/archive-restore.sh`).
A build is picked by **snapshot tags**:
| Repository | Tags (examples) |
|---|---|
| Steam | `manifest=<depot manifest id>`, `date=…`, `branch=public` |
| Quest | `version=20230317`, `version-code=2128210` |
The job defaults to the builds this branch targets: Steam manifest
`7490748483298966814` (2023-03-21) and Quest `version-code=2128210`. Any other
build can be chosen per run. Tick **LIST_SNAPSHOTS** to print the snapshots
matching a filter without building anything.
A restored snapshot is cached on the agent's `dorknet_cache` volume, so each
stock build is downloaded once.
A Quest build needs a resolved hook table for its versionCode, committed under
`DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build
checks the table against the APK's `libil2cpp.so` and stops on a mismatch.
Resolve a table for another build locally with `build.py build --dump-cs`.
## Triggers
- **Push** to the branch (GitHub webhook) rebuilds only what changed:
- `DorkNet.ClientMod/` and the PC scripts rebuild PC;
- `DorkNet.Quest/` rebuilds Quest;
- `Jenkinsfile` and `ci/jenkins/` rebuild both.
- **Build with Parameters** runs a manual build:
- `PLATFORMS` (both / pc / quest), and `PC_FULL_CLIENT` for the full PC client;
- snapshot tags, and per-run repository overrides;
- `PUBLISH_DOWNLOADS` (on by default) to publish to the downloads bucket.
Push builds publish too, but only the platform they rebuilt: the manifest
keeps the other platform's last files.
## Setup
### 1. Jenkins itself
The controller is `ci/jenkins/docker-compose.yml`: stock Jenkins, no build
tools. On Coolify, deploy it as a Docker Compose application from this
repository (base directory `/ci/jenkins`), or paste it into a Compose service.
- **Java 25.** The file defaults to `jenkins/jenkins:lts-jdk25`; set
`JENKINS_IMAGE` to pin a version, e.g. `jenkins/jenkins:2.587-jdk25`.
Jenkins supports Java 25 until September 2029, Java 21 only until September
2027, and Java 17 not at all. Keep the agent on the same Java major version;
its image defaults to Java 25.
- **`restart: unless-stopped`** is in the file: a restart from the Jenkins UI
exits the container with code 5 and relies on Docker to start it again.
- **Turn on "Connect to predefined network"** (Advanced) so the agent can
reach Jenkins by its container name, `jenkins-<uuid>`.
- **Run no builds on it:** **Manage Jenkins → Nodes → Built-In Node →
Configure**, set **Number of executors** to `0`.
Earlier versions of this file built the controller from `ci/jenkins/Dockerfile`,
which is now the agent's image. The service and data volume keep their names,
so an existing Jenkins deployed from this file keeps its jobs and credentials
when redeployed; it switches to the stock image, and its builds wait until
the agent (step 3) is connected.
That redeploy also upgrades Jenkins, and from Java 17 (the old default) to
Java 25, unless `JENKINS_IMAGE` pins your current image. Before it:
1. **Back up the data volume** (`jenkins_home`), e.g. with a Coolify volume
backup or `docker run --rm -v <volume>:/v -v "$PWD":/b alpine tar czf
/b/jenkins_home.tgz -C /v .`, so you can go back to the old image.
2. After it starts, open **Manage Jenkins**. Plugins that need a newer
version show up there as failed to load; update them under **Plugins →
Updates** and restart.
3. Run the job once (**LIST_SNAPSHOTS** is a quick check) before relying on it.
### 2. The agent node
**Manage Jenkins → Nodes → New Node**, a permanent agent:
| Setting | Value |
|---|---|
| Name | `dorknet-builder` |
| Labels | `dorknet-builder` |
| Remote root directory | `/home/jenkins/agent` |
| Launch method | Launch agent by connecting it to the controller, with **Use WebSocket** ticked |
| Number of executors | 1 |
Save, open the node, and copy its **secret**.
### 3. Deploy the agent on Coolify
1. **Create a Docker Compose application** from this repository: build pack
Docker Compose, base directory **`/ci/jenkins`**, compose location
**`/docker-compose.agent.yml`**. The base directory is the build context, so with
`/` the build would pick up the server's `Dockerfile` at the repo root. It
needs no domain. Under Advanced, turn on **Connect to predefined network**.
2. **Set its environment:**
| Variable | Value |
|---|---|
| `JENKINS_URL` | Jenkins' **internal** address, `http://jenkins-<service uuid>:8080/`. Not the public URL, which is behind Cloudflare Access. |
| `JENKINS_SECRET` | the node secret from step 2 |
| `JENKINS_AGENT_NAME` | optional, default `dorknet-builder` |
| `ARCHIVE_PC_REPO` | Steam repository folder on the FTP, e.g. `Rec Room Versions/steam-v2` (plain text, not URL-encoded) |
| `ARCHIVE_QUEST_REPO` | Quest repository folder, e.g. `Rec Room Versions/quest-v2` |
| `ARCHIVE_FTP_TLS` | `explicit` (default), `implicit` or `none` |
| `DOWNLOADS_S3_ENDPOINT` | the downloads bucket's S3 API, internal address, e.g. `http://garage-<service uuid>:3900` |
| `DOWNLOADS_BUCKET` | its name, e.g. `dorknet-downloads` |
3. **Deploy.** The first image build downloads the Android NDK (~1.5 GB) and
takes a while. The node shows as connected in Jenkins once the agent is up.
Both Jenkins and the agent join the server's predefined Coolify network, so
the agent reaches Jenkins by container name. Check the name under the Jenkins
service's containers before using it.
### 4. Plugins
The job needs these plugins:
- Pipeline
- Git
- GitHub (for the push trigger)
- Credentials Binding
- Timestamper
- AnsiColor
- Workspace Cleanup
Most are part of the suggested set.
### 5. Credentials
Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
| ID | Kind | Value |
|---|---|---|
| `archive-ftp` | Username with password | the FTP login |
| `archive-ftp-host` | Secret text | FTP host name only (no `ftp://`, no user, no path) |
| `archive-restic-password` | Secret text | the archive's restic password |
| `quest-keystore` | Secret file | the Quest signing keystore |
| `quest-keystore-password` | Secret text | its store/key password |
| `clientmod-config` | Secret file | your `dorknet-clientmod.json` |
| `quest-config` | Secret file | your `dorknet-quest.cfg` |
| `downloads-s3` | Username with password | S3 key for the downloads bucket: access key ID / secret. Give it read and write on that bucket only. |
**Use the keystore your players' installs were signed with**, the
`dorknet-debug.keystore` from your earlier local builds (password `dorknet`). A
different key means every player has to uninstall before installing a CI
build.
### 6. The job
1. Create a **New Item → Pipeline** (e.g. `dorknet-client`).
2. Under **Pipeline**, choose **Pipeline script from SCM**:
- Git URL `https://github.com/DorkSquadRR/DorkNet.git`
- branch `*/march-2023-03-21`
- script path `Jenkinsfile`
3. Under **Triggers**, tick **GitHub hook trigger for GITScm polling**.
4. Run it once with **Build** so Jenkins learns the parameters. Later runs
offer **Build with Parameters**.
### 7. GitHub webhook
In the repository's **Settings → Webhooks**:
- Payload URL: `https://<your jenkins>/github-webhook/`
- Content type: `application/json`
- Events: just the push event
### 8. The downloads bucket
`publish.py upload` puts the files and a `manifest.json` describing them in
an S3 bucket. The DorkNet server reads that bucket over HTTP on the internal
network and offers the files on its site (`docs/deploy.md` → Client
downloads), so the bucket needs no public route. With Garage (the S3 store on
the Coolify server):
1. **Bucket and key.** Create the bucket, turn on Garage's website access for
it (the server reads it through the web endpoint, port `3902`, which picks
the bucket by the request's Host header), and give Jenkins a key for that
bucket only:
```sh
garage bucket create dorknet-downloads
garage bucket website --allow dorknet-downloads
garage key create jenkins-downloads
garage bucket allow --read --write --key <key id> dorknet-downloads
```
Store the key as the `downloads-s3` credential.
2. **Server.** Point the DorkNet server at the web endpoint:
`DorkNet__Downloads__BaseUrl=http://garage-<service uuid>:3902` and
`DorkNet__Downloads__Host=dorknet-downloads`.
Each release keeps one copy of each file; a new build overwrites it. The
manifest is uploaded last, so the site only ever lists files that are fully
uploaded.
### Disk
The agent's `dorknet_cache` volume holds the restored stock builds, several GB
each. Jenkins keeps the artifacts of the last 3 runs, on its own volume.
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# archive-restore — pull a Rec Room build out of the restic build archive.
#
# archive-restore list <repo-path> [tag[,tag…]]
# archive-restore restore <repo-path> <tag[,tag…]> <cache-dir> [restic restore args…]
#
# <repo-path> is the restic repository's folder on the archive FTP, e.g.
# "Rec Room Versions/steam-v2". Snapshots are picked by tag, e.g.
# "manifest=7490748483298966814" (Steam) or "version-code=2128210" (Quest);
# several tags separated by commas must all match. The newest match wins.
#
# `restore` writes the snapshot to <cache-dir>/<snapshot-id>/ and prints that
# path. A snapshot already restored there is reused, so a multi-GB build is
# only downloaded once. Extra arguments go to `restic restore` (e.g.
# --exclude /.DepotDownloader).
#
# restic reaches the FTP through its rclone backend. Everything secret comes
# from the environment (Jenkins injects it from credentials):
# ARCHIVE_FTP_HOST FTP host name
# ARCHIVE_FTP_USER FTP user
# ARCHIVE_FTP_PASSWORD FTP password
# RESTIC_PASSWORD restic repository password
# Optional: ARCHIVE_FTP_PORT (21), ARCHIVE_FTP_TLS (none | explicit | implicit,
# default explicit), RESTIC_CACHE_DIR.
set -euo pipefail
mode="${1:?usage: archive-restore list|restore <repo-path> …}"
repo_path="${2:?repo path required}"
tags="${3:-}"
: "${ARCHIVE_FTP_HOST:?ARCHIVE_FTP_HOST is not set}"
: "${ARCHIVE_FTP_USER:?ARCHIVE_FTP_USER is not set}"
: "${ARCHIVE_FTP_PASSWORD:?ARCHIVE_FTP_PASSWORD is not set}"
: "${RESTIC_PASSWORD:?RESTIC_PASSWORD is not set}"
# rclone remote "rrarchive", configured purely through its env-var config.
export RCLONE_CONFIG_RRARCHIVE_TYPE=ftp
export RCLONE_CONFIG_RRARCHIVE_HOST="$ARCHIVE_FTP_HOST"
export RCLONE_CONFIG_RRARCHIVE_USER="$ARCHIVE_FTP_USER"
export RCLONE_CONFIG_RRARCHIVE_PORT="${ARCHIVE_FTP_PORT:-21}"
RCLONE_CONFIG_RRARCHIVE_PASS="$(printf '%s' "$ARCHIVE_FTP_PASSWORD" | rclone obscure -)"
export RCLONE_CONFIG_RRARCHIVE_PASS
case "${ARCHIVE_FTP_TLS:-explicit}" in
explicit) export RCLONE_CONFIG_RRARCHIVE_EXPLICIT_TLS=true ;;
implicit) export RCLONE_CONFIG_RRARCHIVE_TLS=true ;;
none) ;;
*) echo "archive-restore: ARCHIVE_FTP_TLS must be none, explicit or implicit" >&2; exit 2 ;;
esac
export RESTIC_REPOSITORY="rclone:rrarchive:${repo_path}"
tag_args=()
[ -n "$tags" ] && tag_args=(--tag "$tags")
case "$mode" in
list)
restic snapshots --no-lock "${tag_args[@]}"
;;
restore)
cache="${4:?cache dir required}"
[ -n "$tags" ] || { echo "archive-restore: restore needs a tag filter" >&2; exit 2; }
shift 4
id="$(restic snapshots --no-lock --json "${tag_args[@]}" \
| python3 -c 'import json,sys; s=sorted(json.load(sys.stdin), key=lambda x: x["time"]); print(s[-1]["short_id"] if s else "")')"
if [ -z "$id" ]; then
echo "archive-restore: no snapshot in '$repo_path' has tags '$tags'" >&2
exit 1
fi
dest="$cache/$id"
if [ -f "$dest.restored" ]; then
echo "archive-restore: reusing $id from $dest" >&2
else
echo "archive-restore: restoring $id ($tags) from '$repo_path'" >&2
rm -rf "$dest" "$dest.restored"
mkdir -p "$dest"
restic restore --no-lock "$id" --target "$dest" "$@" >&2
touch "$dest.restored"
fi
echo "$dest"
;;
*)
echo "archive-restore: unknown mode '$mode'" >&2
exit 2
;;
esac
+41
View File
@@ -0,0 +1,41 @@
# DorkNet build agent on Coolify. Deploy as its own Docker Compose application
# from this repository with base directory /ci/jenkins, compose location
# /docker-compose.agent.yml and "Connect to predefined network" on. It needs no
# domain: it dials out to Jenkins over WebSocket on Docker's internal network.
# Set the variables in the resource's Environment Variables; see
# ci/jenkins/README.md. The controller is docker-compose.yml.
services:
agent:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
# Reap the processes build tools leave behind (dotnet build servers,
# Gradle daemons, adb), as the inbound-agent image's docs ask for.
init: true
environment:
# Jenkins as seen from inside Docker (e.g. http://<jenkins container
# name>:8080), not the public URL, which is behind Cloudflare Access.
# Both are needed at runtime only; keep them out of the image build so
# the node secret never reaches a build argument.
JENKINS_URL: ${JENKINS_URL}
JENKINS_AGENT_NAME: ${JENKINS_AGENT_NAME:-dorknet-builder}
JENKINS_SECRET: ${JENKINS_SECRET}
JENKINS_WEB_SOCKET: "true"
JENKINS_AGENT_WORKDIR: /home/jenkins/agent
# Where the stock builds live in the archive (restic repository folders
# on the FTP, plain text). Overridable per run.
ARCHIVE_PC_REPO: ${ARCHIVE_PC_REPO:-}
ARCHIVE_QUEST_REPO: ${ARCHIVE_QUEST_REPO:-}
ARCHIVE_FTP_TLS: ${ARCHIVE_FTP_TLS:-explicit}
# The downloads bucket (S3 API, internal address) the builds are
# published to. See ci/downloads/publish.py.
DOWNLOADS_S3_ENDPOINT: ${DOWNLOADS_S3_ENDPOINT:-}
DOWNLOADS_BUCKET: ${DOWNLOADS_BUCKET:-}
volumes:
- dorknet_cache:/home/jenkins/dorknet-cache
- agent_work:/home/jenkins/agent
volumes:
dorknet_cache:
agent_work:
+25
View File
@@ -0,0 +1,25 @@
# The Jenkins controller: stock Jenkins on Java 25. It runs no builds; the
# client builds run on the agent (docker-compose.agent.yml). On Coolify,
# deploy it as a Docker Compose application from this repository (base
# directory /ci/jenkins) or paste it into a Compose service, with "Connect to
# predefined network" on so the agent can reach it. See ci/jenkins/README.md.
#
# Earlier versions of this file built the controller from ci/jenkins/Dockerfile,
# which is now the agent's image. The service name and data volume are the
# same, so redeploying an existing Jenkins from this file keeps its jobs and
# credentials; its builds then wait for the agent.
services:
jenkins:
# Pin a version with e.g. jenkins/jenkins:2.587-jdk25. Keep the agent on
# the same Java major version (its image defaults to Java 25).
image: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk25}
# A restart from the Jenkins UI exits with code 5 and relies on Docker to
# start it again.
restart: unless-stopped
expose:
- "8080"
volumes:
- jenkins_home:/var/jenkins_home
volumes:
jenkins_home:
+33
View File
@@ -412,6 +412,39 @@ for building and installing the patched APK.
---
## Client downloads
Optional. When CI publishes the client builds to a bucket
(`ci/downloads/publish.py`, see `ci/jenkins/README.md`), the server can offer
them on the public site:
- a **Downloads** page (`/downloads`) listing every published file with its
size and SHA-256;
- **Latest build** on the **Quest** page, which installs the newest Quest
package straight from the server: the browser reads it with HTTP Range
requests, so nothing is downloaded first.
The server reads `manifest.json` and the files over plain HTTP, from inside
your network, and streams them to the browser. Only files the manifest lists
are served. The bucket itself never needs to be public.
```env
# Where manifest.json is: e.g. Garage's web endpoint on the internal network.
DorkNet__Downloads__BaseUrl=http://garage-<service uuid>:3902
# Optional Host header, for web endpoints that pick the bucket by host name
# (Garage serves the bucket whose name matches it).
DorkNet__Downloads__Host=<bucket name>
```
Unset, the site hides both features. The builds contain Rec Room's own game
files: only turn this on when the site is behind an access proxy (e.g.
Cloudflare Access) that admits just your community. The downloads routes
answer only on the apex host, where the site lives, and return 404 on every
other host the server accepts, so leaving the game's own subdomains open for
the client doesn't expose the files. Put the apex behind the proxy.
---
## Photon Cloud
Use your own Photon AppIds. Rec Room's original Photon AppIds reject
+19 -1
View File
@@ -55,6 +55,17 @@ an account, never matched by the cached-login lookup
players are keyed by `deviceId` alone, exactly like the PC Steam-emulator path.
A real Meta platform id (the Meta build) still tags and lists normally.
Because the placeholder never lists a remembered account, the title screen
would normally create a new account on every fresh headset. With
`AutoCreateAccount = false` (the default) the hook library opens the sign-in
page instead, so players log into their existing DorkNet account; set it to
`true` only on a server that allows sign-ups.
On a headset that reaches Meta, the Oculus platform also refuses the
"logged-in user" request for the renamed app ("Unable to load Oculus user
account"). The no-Meta build passes that refusal and hands the game the same
placeholder user id `1`.
Thanks to [RecFlare](https://github.com/recflare) for documenting this
pitfall in their Quest client notes.
@@ -74,6 +85,13 @@ pitfall in their Quest client notes.
installer scripts; the app appears as **DorkNet** under Unknown Sources.
Share the `.xapk` however you like: the page reads it from the player's own
disk, so your server never hosts game files.
5. Optionally, let players install the latest CI build without downloading it
first: point the server at the downloads bucket CI publishes to
(`DorkNet__Downloads__BaseUrl`, see `deploy.md` → Client downloads). The
Quest page then offers **Latest build** next to **Choose file** and streams
the package from the server during the install, and the site gets a
**Downloads** page. The builds contain game files, so only do this with
the site behind an access proxy.
No server component is Quest-specific beyond the placeholder-platform-id
handling above.
handling above and the optional downloads proxy.
+149 -46
View File
@@ -120,17 +120,19 @@ Defect classes:
## Realtime (SignalR) surface
Not every request is HTTP. The client opens a SignalR hub at `{0}hub/v1`
(`RecNet.Runtime/OMHPBIEHOHN`, negotiate + `hub/v1`).
(`RecNet.Runtime/OMHPBIEHOHN`, negotiate + `hub/v1`). The full push
contract — every handler key, its payload, and what DorkNet sends — is in
[recroom-2023-notifications.md](recroom-2023-notifications.md).
- Server→client messages arrive on the hub method named **`"Notification"`**
(singular) carrying a **single `string`** argument whose content is JSON
`{"Id":..., "Msg":...}`. The handler is registered as `Action<string>`.
Sending `ReceiveNotification`, or passing an object instead of a string,
makes every push silently vanish.
- Client→server: `SubscribeToPlayers` / `UnsubscribeFromPlayers`, plus
`"SubscribeTo"` / `"UnsubscribeFrom"` prefixes composed at runtime.
- Presence pushes are routed by the friend graph server-side, so the
subscription calls are advisory rather than load-bearing.
- Client→server: `SubscribeToPlayers` (`{PlayerIds}`), plus
`SubscribeTo` / `UnsubscribeFrom` (`{Channels}`, always empty).
- `SubscribeToPlayers` is load-bearing: it is how the client gets presence
for players who aren't friends (people in the room, an open profile).
### Push dispatch keys — the numeric-vs-name trap
@@ -160,25 +162,26 @@ So **send the number**, except for these six, which are string-keyed:
| 95 CommunityBoardUpdate | `CommunityBoardUpdate` |
Ids the 2023 client registers NO handler for — sending them is a no-op:
4, 15 (numeric form), 21, **24 ModerationRoomBan**, 40, 85. Room bans must
therefore go out as `ModerationKick` (22) with `IsBan` set; the moderation
4, 15 (numeric form), 21, **24 ModerationRoomBan**, 40, 85. The moderation
manager registers only 22, `ModerationUnkick` and 23
(`RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335`).
(`RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335`), and a kick (22) only sends
the player to their dorm, so bans go out as `Logout` (6) with Reason 4
(Banned); see recroom-2023-notifications.md.
Ids the client handles that this server never sends (dead client features,
available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of
string channels — `GoToFailure`, `PhotonAccessToken`, `AppVersionUpdate`,
string channels — `GoToFailure`, `AppVersionUpdate`,
`GameConfig.Refresh`, `ReputationUpdate`, `KeepsakeInstanceAdded/Removed`,
`RoomCurrencyCreated/Modified/Deleted`, `RoomCommentDeleted` and others.
### Hub methods
The client invokes exactly one server method: `SubscribeToPlayers`, with a
`{PlayerIds: List<int>}` payload, fire-and-forget. The `SubscribeTo`/
`UnsubscribeFrom` composition exists in the binary but has no callers in
this build, and `UnsubscribeFromPlayers` does not appear at all — the
server's extra hub methods are unused but harmless. Presence is routed
server-side by the friend graph, so these calls are advisory.
The client sends `SubscribeToPlayers` with a `{PlayerIds: List<int>}`
payload, fire-and-forget, on every (re)connect and whenever the set changes.
The `SubscribeTo`/`UnsubscribeFrom` composition runs on connect but its
channel set is always empty, and `UnsubscribeFromPlayers` does not appear at
all. The server keeps each connection's subscriptions as `presence:{id}`
SignalR groups and pushes those players' presence to them.
### Negotiate
@@ -257,7 +260,8 @@ verified against the binary before the change, and the full test suite passes.
| `playlists accessibility/levelvoting/restrictions/warning` | PUT 405, and values were discarded | PUT added and values persisted |
| `SignalR push `Id` (notify)` | enum NAME sent for most ids; client only registered numbers — pushes vanished | numeric by default, with the six string-keyed ids listed explicitly |
| `SignalR SubscriptionUpdateRoom` | sent as `SubscriptionUpdateRoom`; client listens on `RoomUpdate` | sent as `RoomUpdate` |
| `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan |
| `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan (later replaced by Logout Reason 4, below) |
| `Notification payloads (Oct 2026 audit)` | RelationshipChanged sent `{Reason, From}`; ModerationKick used the nested 2020 shape and lost every reason; bans, deletes and takeovers only kicked to the dorm; Logout sent an enum NAME and was dropped; ServerMaintenance sent `{Message}`; ConsumableMapping, PlayerEvent 80/81/83, admin RoomUpdate and level-up pushes had wrong or missing payloads; SubscribeToPlayers was ignored | every payload matches the client DTO; bans/deletes/takeovers are Logout with a numeric Reason; SubscribeToPlayers drives `presence:{id}` groups; see recroom-2023-notifications.md |
| `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect pushes presence (online even with no room); a drop while the player still heartbeats waits one activity window before pushing offline |
| `POST matchmake/none` | re-committed the previous room (or the dorm), so the next heartbeat answered with a room while the client held its offline instance (-1): "presence out-of-sync", offline load cancelled, player dragged back | player is online with no room; any pending join is abandoned; heartbeat answers `roomInstance: null` until the next matchmake |
| `Presence fan-out to friends` | only the immediate /goto commit pushed; deferred commits (matchmake/dorm, private hops), matchmake/none, logout, failed joins and statusvisibility changes were silent, and `isOnline` was derived from "has a room" | one fan-out (PresenceBroadcastService) for every transition, explicit `isOnline`, stored `statusVisibility`, never sent to the subject |
@@ -618,6 +622,83 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
8. UNKNOWNS: rooms/requiring/{mode} response element type could not be resolved from the ISIL metadata token (stored internally as a restricted-rooms list; server already implements rooms/requiring/{restriction}). The exact wire type of BNLPBMJJOMM.Value (subroom permissions) is a variant read through the shared value reader — keys are certain, scalar type is not.
#### Sub-room ids, IsRRO and the instance browser
These cut across the whole rooms surface.
- **Every room and playlist mutation answers `{success, value, error}`.**
They all go through one generic helper,
`NLDBPDCNNCF.BDBHCEGKGDC<TResult>(opName, verb, route, …)` (RVA 0x35E50A0,
`Call 0x1835E50A0` at 47 call sites in NLDBPDCNNCF.txt). It is the same
helper and the same `BDBHCEGKGDC<FGCPNAACHIK>` instantiation token
(`[0x1884C1948]`) that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
clone is proven in game to need the envelope. A bare room-details body has
no `success`; the helper rejects it with no message, and the player sees
"Exception of type 'CEMNLBKJABA' was thrown." (CEMNLBKJABA is the RecNet
request exception, base BCPHBJHOIEM : Exception, built with a null
message) although the server applied the change. This is what broke the
co-owner invite in game after the role fixes. Routes: rooms/{0}/{accessibility,
allow_new_users, automute, cloning, comments, creator, description, image,
loadscreen, max_player_calculation_mode, min_level, modify, name,
promo_external, promo_images, restrictions, tags, voice_chat_encryption,
warning}, rooms/{0}/roles/{1} and /invite, rooms/{0}/subrooms (POST),
rooms/{0}/subrooms/{1} (DELETE) and its accessibility, clone, maxplayers,
modify, move, name, permissions, publish_save, plus playlists (POST) and
playlists/{0}/{accessibility, description, image, levelvoting, name,
restrictions, rooms/{1}, tags, warning}. `value` is the full FGCPNAACHIK
(playlist DTO for playlists). Refusals are HTTP 200 with `success:false`
and a message in `error`, since a 403/404 also shows only the message-less
exception. Server: `Controllers/RecNetEnvelopeAttribute.cs` on each handler.
Wherever a row below says "FGCPNAACHIK full room details" for one of these
routes, read it as the envelope's `value`.
- **Sub-room ids are OrderIndex + 1 on the wire.** The client refuses to load
a sub-room whose id is not positive: `SubroomModel.DOKCPLJOKIE`
(Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) is
`RoomId > 0 && SubroomId > 0`, and `Model`1.LNPEMMMGCPC` skips the load when
it fails. With the first sub-room sent as 0, its Room Settings > Subrooms row
never fetched its details and showed no name, and every row action ran
against a null sub-room. The wire id is now `RoomSceneEntity.OrderIndex + 1`
in the room details `SubRooms[]`, the sub-room routes
(`rooms/{0}/subrooms/{1}/*`), `matchmake/room/{0}/{1}`, the room-instance
JSON (`RoomInstanceDto.subRoomId`), the instance browser and the sub-room
save DTOs; incoming 0 is accepted as the first sub-room. Storage (RoomScenes,
PrivateInstances, RoomDataBlobs, presence) keeps the OrderIndex.
See `DorkNet.Server/Services/RoomSceneIds.cs`.
- **Every room owns a real, named entry sub-room.** Cloning a template that
has no RoomScenes rows now creates a "Home" row from the clone's own
location, blob and capacity, and a startup pass
(`RoomService.RepairRoomScenesAsync`) adds the row to existing rooms that
lack one and names blank rows ("Home", "Subroom N").
- **`IsRRO` is false for every player-owned room.** RoomModel's owner UI —
get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom, CanChangeRoomCreator,
CanChangeRoomSetting, CanChangeCustomWarning, CanChangeLoadingScreen,
CanCreateNewSubroom (RoomModel.txt:275, 429, 560, 682, 1002, 1473, 1680,
1816) — requires `!IsRRO`, so a clone of a Rec Room Original template
advertised as RRO could not be published ("Cannot publish room") or have its
settings changed. The load pipeline (RecRoom.RoomLoading.Runtime) never
reads the flag. `RoomService.WireIsRRO` = AG room still owned by the system
account; `RoomEntity.IsAGRoom` keeps its server-side meaning.
- **Publish preconditions are client-side.** Besides `!IsRRO`, the client
requires role ≥ CoOwner, not a dorm, a valid name (2–128 chars) and a room
image other than `DefaultRoomImage.jpg` ("Cannot publish room: Missing room
photo") before it sends `PUT rooms/{0}/accessibility accessibility=1`.
- **Room settings are creator or accepted co-owner.** Sub-room mutations
(name, max players, accessibility, modify, add, delete, duplicate, restore,
permissions) used to be creator-only and the room and sub-room renames also
let moderators in; all now use the same creator-or-co-owner gate as the
bare room mutations.
- **`room/{0}/instances`** (Matchmaking.KAIPGDGACGG, Matchmaking.txt:13966,
verb 0) is shown unfiltered by the client (RoomInstancePagedGrid, current
instance first, then oldest CreatedAt), which derives PlayerCount from
PlayerIds and SubroomName from the room's SubRooms by SubRoomId. The server
used to return nothing at all on Redis deployments (no per-room index),
dropped private instances from presence and re-added only the caller's own
with no players, counted crashed clients, and always sent IsFull false. It
now lists live instances from a per-room presence index, with the real
players (live activity marker only), IsFull from capacity, and private
instances for the people allowed into them.
#### Endpoints
| Verb | Route | Request | Response |
@@ -626,7 +707,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
| POST | `api/rooms/v1/verifyRole` | x-www-form-urlencoded fields: roomId (Int64, current room from Matchmaking ObscuredLong), role (Int32 enum), context (String). Keys are camelCase. | none parsed — client ignores the body; return 200 |
| POST | `api/rooms/v2/report` | x-www-form-urlencoded fields (PascalCase!): RoomId (Int64), RoomKeyId (Nullable<Int64>), Details (String), ReportCategory (Int32 enum FPIBGPIAOBI/BDOGOIGCKMK) | none parsed — logged as 'Failed to report room' on error; return 200 |
| GET | `featuredrooms/current` | none | JBHJKCCFCIO: {"FeaturedRoomGroupId": Int64, "Name": String, "Rooms": [COGJIOGPNGD {"ImageName": String, "RoomId": Int64, "RoomName": String}]} (casing-tolerant) |
| GET | `room/{0}/instances` | none | JSON array: [PNDCMIMEJLD {"RoomInstanceId": Int64, "RoomId": Int64, "SubRoomId": Int64, "PlayerIds": [Int32], "IsFull": Boolean, "CreatedAt": DateTime string}] (casing-tolerant) |
| GET | `room/{0}/instances` | none | JSON array: [PNDCMIMEJLD {"RoomInstanceId": Int64, "RoomId": Int64, "SubRoomId": Int64 (wire id, see "Sub-room ids" below), "PlayerIds": [Int32], "IsFull": Boolean, "CreatedAt": UTC DateTime string}] (casing-tolerant). Live instances only (at least one player with a live activity marker); private instances only for the creator, accepted role holders, the instance owner, its invitees and players inside |
| PUT | `roominstance/{0}/inprogress` | x-www-form-urlencoded field: inProgress (Boolean) | none parsed ('Failed to modify current room's game-in-progress state' on error); return 200 |
| PUT | `roominstance/{0}/markprivate` | none (empty body) | none parsed ('Failed to mark room instance {0} as private'); return 200 |
| POST | `roominstance/{0}/reportjoinresult` | x-www-form-urlencoded field: result (Int32 enum MEIJCIADDHJ) | none parsed ('Failed to report room join result'); return 200 |
@@ -653,7 +734,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
| GET | `rooms/visitedby/me` | query params: skip (Int32), take (Int32) | [NEMINAEBALC] |
| GET | `rooms/visitedby/{0}` | query params: skip (Int32), take (Int32) | [NEMINAEBALC] |
| GET (2 overloads) + DELETE | `rooms/{0}` | GET summary: none. GET details: query params include (Byte bitmask), unityAssetTarget (String), unityAssetVersion (Int32) from closure CAEBODLFAMA. DELETE: none. | GET summary → NEMINAEBALC. GET details → FGCPNAACHIK = NEMINAEBALC keys PLUS: "DataBlob" String, "DataBlobHash" String, "LoadScreens": [{"ImageName","Title","Subtitle" all String}], "PromoImages": [String], "PromoExterna |
| PUT | `rooms/{0}/accessibility` | x-www-form-urlencoded field: accessibility (Int32 enum JHAOFLCJNAL) | FGCPNAACHIK full room details (see rooms/{0}) — client re-parses the whole object; missing keys NRE |
| PUT | `rooms/{0}/accessibility` | x-www-form-urlencoded field: accessibility (Int32 enum JHAOFLCJNAL: Private 0, Public 1, Unlisted 2). This is the "Publish room" button (RoomModel.TryPublishRoom → DBKKLDIPCHP(1)); unpublish sends 0 | FGCPNAACHIK full room details (see rooms/{0}) — client re-parses the whole object; missing keys NRE |
| PUT | `rooms/{0}/allow_new_users` | form field: allowNewUsers (Boolean) | FGCPNAACHIK full room details |
| PUT | `rooms/{0}/automute` | form field: disable (Boolean) — NOTE key is 'disable', maps to DisableMicAutoMute | FGCPNAACHIK full room details |
| GET + POST | `rooms/{0}/bans` | GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. |
@@ -677,9 +758,9 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
| PUT + DELETE | `rooms/{0}/promo_external` | form fields: type (Int32 enum OJGHPAELGBO), reference (String) — same keys on both verbs | FGCPNAACHIK full room details |
| PUT + DELETE | `rooms/{0}/promo_images` | form field: imageName (String) on both verbs | FGCPNAACHIK full room details |
| PUT | `rooms/{0}/restrictions` | form fields: supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR (Booleans) | FGCPNAACHIK full room details |
| GET | `rooms/{0}/roles` | none | [EFHPLDPNGIM {"AccountId": Int32, "Role": Int32 enum, "InvitedRole": Int32 enum, "LastChangedByAccountId": Int32}] (casing-tolerant) |
| GET + PUT | `rooms/{0}/roles/{1}` | GET: none. PUT: form field role (Int32 enum OMMBGJMJJPN). | GET → EFHPLDPNGIM single object. PUT → FGCPNAACHIK full room details. |
| PUT | `rooms/{0}/roles/{1}/invite` | form field: role (Int32 enum) | FGCPNAACHIK full room details |
| GET | `rooms/{0}/roles` | none | [EFHPLDPNGIM {"AccountId": Int32, "Role": Int32 enum, "InvitedRole": Int32 enum, "LastChangedByAccountId": Int32}] (casing-tolerant). One row per account; the creator is Role 255 (Creator) |
| GET + PUT | `rooms/{0}/roles/{1}` | GET: none. PUT: form field role (Int32 enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30, TemporaryCoOwner 31, Creator 255). Also how an invitee ACCEPTS (own id, role 30) and how anyone leaves their role (own id, role 0) | GET → EFHPLDPNGIM single object. PUT → FGCPNAACHIK full room details. |
| PUT | `rooms/{0}/roles/{1}/invite` | form field: role (Int32 enum). Owner invites with 30; the invitee REJECTS by sending role 0 here on their own id | FGCPNAACHIK full room details |
| GET | `rooms/{0}/similar` | none | single OPGDCAELKMJ: {"SeedRoom": NEMINAEBALC, "Rooms": [NEMINAEBALC]} |
| POST | `rooms/{0}/subrooms` | form field: name (String) — new subroom name | FGCPNAACHIK full room details (with the new SubRooms entry) |
| DELETE | `rooms/{0}/subrooms/{1}` | none | FGCPNAACHIK full room details |
@@ -690,7 +771,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
| PUT | `rooms/{0}/subrooms/{1}/modify` | form fields: name (String), accessibility (Int32 enum), maxPlayers (Int32) | FGCPNAACHIK full room details |
| POST | `rooms/{0}/subrooms/{1}/move` | form field: newRoomId (Nullable<Int64> — target room) | FGCPNAACHIK full room details |
| PUT | `rooms/{0}/subrooms/{1}/name` | form field: name (String) | FGCPNAACHIK full room details |
| PUT | `rooms/{0}/subrooms/{1}/permissions` | serialized list of BNLPBMJJOMM permission entries; per-entry keys (from its serializer): Role (Int32), Type (Int32), Permission (Int32), Override (Boolean), Value (variant) | FGCPNAACHIK full room details |
| PUT | `rooms/{0}/subrooms/{1}/permissions` | raw JSON array (BNDIAONDFFF.FJLLPHFOOJJ, not form) of BNLPBMJJOMM cells, keys from formatter HDOJMEBCAGP: Permission (String), Role (OMMBGJMJJPN), Override (Boolean), Type (FEOJFGECKGH: Bool 0, Int 1, StringList 2), Value (String). The editor sends only the changed cell | FGCPNAACHIK full room details |
| POST | `rooms/{0}/subrooms/{1}/publish_save` | form field: subRoomDataSaveId (Int64) | FGCPNAACHIK full room details |
| GET | `rooms/{0}/subrooms/{1}/saves` | query params: skip (Int32), take (Int32) | paged object GJDIKIMLCHA: {"Results": [JKIFFPPAJNK {"SubRoomDataSaveId" Int64, "SubRoomId" Int64, "UnityAssetId" String, "DataBlob" String, "DataBlobHash" String, "Description" String, "SavedByAccountId" Int64, "SavedOnP |
| PUT | `rooms/{0}/tags` | form fields: tag (repeated String), autoTag (repeated String) | FGCPNAACHIK full room details |
@@ -944,6 +1025,8 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:4
**Fix.** Change ApplyAndReturn to return RoomsController.BuildRoomServerDetails (load scenes+roles like BuildRoomServerDetailsWithRolesAsync) instead of ToWireRoom — this one fix repairs the response of every bare-path room mutation.
**Fixed.** ApplyAndReturn returns the full details. This route is also the "Publish room" button; publishing a cloned room still failed because the clone was advertised as `IsRRO`, which hides and refuses Publish client-side. Player-owned rooms are now never RRO on the wire (see "Sub-room ids, IsRRO and the instance browser" above).
##### `PUT rooms/{0}/allow_new_users` — SHAPE_MISMATCH (degraded)
Twins + form key allowNewUsers OK; response is ToWireRoom (same ApplyAndReturn defect as accessibility).
@@ -1032,29 +1115,33 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:8
**Fix.** Same ApplyAndReturn fix.
##### `GET rooms/{0}/roles` — MISSING (degraded)
##### `GET rooms/{0}/roles`, `GET + PUT rooms/{0}/roles/{1}`, `PUT rooms/{0}/roles/{1}/invite` — FIXED
Handler + wire shape are correct ([{AccountId,Role,InvitedRole,LastChangedByAccountId}], BuildRoomAccountRoleWire :4003-4014) but there is no roomserver twin → the permissions screen's list fetch 404s under the 2023 prefix.
Was: the roomserver/ twins were added earlier, but three defects remained.
(1) The creator was listed as Role 30 (CoOwner). The client takes the local
role from the Roles list (`FGCPNAACHIK.ONECLLALJEO`, first AccountId match)
and the permission editor only allows roles strictly below your own, with
Creator (255) the one unconditional pass (`OMJKHJLFOCO.DIIMLMKEJLC`,
RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367), so the creator could
not edit Co-Owner or Creator permissions. (2) Only the creator or a co-owner
could call either route, so an invitee could never accept (own id, role 30)
or reject (`/invite` on own id, role 0), and re-inviting an accepted co-owner
demoted them to pending. (3) Nobody was told: the invitee's prompt is a
message, Type 62 RoomCoOwnerInvited with RoomId (KPFKPIKDBFJ, factory
OCGKOJMFLAI.ALEHDHAEFOK), and none was sent.
Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3695`
**Fixed.** Creator is 255 and listed once (`RoomRoleRules.BuildWires`); one
row per account with accepted Role and pending InvitedRole side by side.
`RoomRoleRules.CheckChange` enforces the ladder: the creator may set any role
on anyone but themselves; otherwise the actor must be an accepted co-owner and
both the target's current or invited role and the requested role must be
strictly below theirs; anyone may drop their own role or decline their invite;
an invitee may accept exactly the invited role. Messages 62 (invite), 60
(added) and 61 (removed) go to the target, answered invites are marked read,
and the full room goes out as `RoomUpdate` to everyone in the room plus the
changed player.
**Fix.** Add [HttpGet("roomserver/rooms/{roomId:long}/roles")] (and twins for the two routes below).
##### `GET + PUT rooms/{0}/roles/{1}` — MISSING (degraded)
Handlers are contract-correct: GET returns a single role wire object; PUT reads form 'role' (ReadClientRoomRoleAsync :3944-3973) and returns full BuildRoomServerDetailsWithRolesAsync. But neither has a roomserver twin → 404 under the 2023 prefix.
Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3818 (GET), 3835 (PUT)`
**Fix.** Add roomserver twins for GET and PUT/POST.
##### `PUT rooms/{0}/roles/{1}/invite` — MISSING (degraded)
Handler correct (form role, Accepted=false row, full-details response); no roomserver twin → 404 under prefix.
Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3879`
**Fix.** Add roomserver twin.
Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs` (`GrantRole`, `InviteRole`, `ChangeRoomAccountRoleAsync`), rules in `DorkNet.Server/Services/RoomRoleRules.cs`
##### `POST rooms/{0}/subrooms/{1}/clone` — SHAPE_MISMATCH (degraded)
@@ -1080,13 +1167,29 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2656`
**Fix.** Drop the [FromBody] parameter; return BuildRoomServerDetailsWithRolesAsync.
##### `PUT rooms/{0}/subrooms/{1}/permissions` — STUB (degraded)
##### `PUT rooms/{0}/subrooms/{1}/permissions` + `GET photon_access_token` — FIXED
Twins exist but the handler accepts SubRoomModifyRequest (Name/MaxPlayers/IsSandbox/CanMatchmakeInto) — the client actually sends a serialized LIST of BNLPBMJJOMM permission entries {Role,Type,Permission,Override,Value}. No permission matrix is ever stored (there is no storage for it), and the client body would 415/no-op. Response SceneWire. The role-permission matrix silently never saves.
Was: the PUT accepted the matrix and dropped it (no storage), and
`photon_access_token` always answered a hardcoded seed, so a permission
change never survived a reload and nobody else in the room saw it.
Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3391`
**Fixed.** Cells persist in `RoomRolePermissions` (one row per room, sub-room
OrderIndex, role, permission; upsert, cells not sent are untouched).
`photon_access_token` (`ALIIIAGAPND {RoomInstanceId, PhotonAccessToken,
Permissions}`, now also under `roomserver/`) serves the defaults with the
caller's current sub-room cells applied on top, and every save pushes the
same body as the `PhotonAccessToken` hub message (registered as
`Action<ALIIIAGAPND>`, IBEOONPEELF.txt:519) to everyone in that sub-room. Writes
follow the client's own rule: the creator may edit every role row, a co-owner
only rows below 30, nobody else may write. Role and Type are accepted as
numbers, numeric strings or enum names.
**Fix.** Parse the request as a list of {Role,Type,Permission,Override,Value}; persist per-subroom permission rows (new table); echo full room details. Scope explicitly if deferred — currently it is a silent stub.
Unproven: the client's permission-id spelling. The defaults keep the numeric
ids the server has always sent ("13" maker pen, "20"–"23"); RecFlare sends
names such as `CAN_USE_MAKER_PEN`. Stored cells are echoed exactly as the
client wrote them, so the client's own edits round-trip either way.
Handler: `RoomsController.SubRoomPermissions`, `MatchController.PhotonAccessToken`, `DorkNet.Server/Services/RoomPermissionMatrix.cs`
##### `POST rooms/{0}/subrooms/{1}/publish_save` — SHAPE_MISMATCH (degraded)
+140
View File
@@ -0,0 +1,140 @@
# 2023 client notifications (realtime push)
The contract between DorkNet and the 2023-03-21 client's notification hub.
Sources: the client's RecNet.Runtime ISIL (`C:\tmp\recroom-2023-03-21-isil`,
obfuscated names given as found there), cross-checked against RecFlare
(build 20230414) where useful. Code: `DorkNet.Server/Hubs/NotifyHub.cs`,
`Services/NotificationService.cs`, `Services/PresenceBroadcastService.cs`,
`Services/SystemNotificationService.cs`. Tests:
`DorkNet.Server.Tests/NotificationContractTests.cs`.
## Connection
- The hub is `{notify host}/hub/v1` (nameserver key `Notifications`). The
client opens it once, at login.
- **Negotiate**: `POST …/hub/v1/negotiate`, no `negotiateVersion`. The client reads
`connectionId` and `availableTransports`, and accepts only `WebSockets`.
- **WebSocket**: `…/hub/v1?id=<connectionId>`, JSON protocol
(`{"protocol":"json","version":1}`), no MessagePack.
- **Auth**: `Authorization: Bearer` on both negotiate and the upgrade. No
`access_token` query string.
- **Keep-alive**: the client pings every 15 s from its main thread and has
no server timeout of its own. The server pings every 10 s and allows 180 s
of client silence, because a long room-load hitch pauses the client's
pings.
- **Reconnects: at most 3 per login.** The client's attempt counter is never
reset after a successful reconnect. Once the budget is spent the hub stays
dead, with no message, until the next login. The server must never close a
healthy connection.
## Envelope
Every push invokes the hub method **`Notification`** with **one string
argument**, whose content is JSON:
```json
{"Id":"<key>","Msg":{...}}
```
- **`Id`** is a JSON string. It is looked up case-sensitively in the
client's handler table, and a miss drops the push silently.
- Handlers registered through the enum overload use the decimal string of
the id (`"2"`).
- The rest use a name (see the table below).
- `NotificationService.WireName` maps `PushNotificationId` to the key.
- **`Msg`** is an inline JSON object of the handler's DTO, never
JSON-in-a-string.
- It is parsed strictly. Keys may be PascalCase, camelCase or all
lowercase, and unknown keys are skipped.
- A value of the wrong type (a number where a string is expected, a
string where an enum number is expected) throws, and the whole push is
dropped.
- A key that is missing takes its default value, which usually means the
push "works" but does nothing.
## Hub calls (client → server)
| Method | Payload | Server |
|---|---|---|
| `SubscribeToPlayers` | `{PlayerIds: List<int>}`: the client's relationships, the players around it, and itself. Sent on every (re)connect and whenever the set changes, always the complete set. | Replaces the connection's subscriptions. The connection is put in a `presence:{id}` group per player (own id skipped), and `PresenceBroadcastService` pushes those players' presence to those groups. Only presence, never the subject's other pushes. An unreadable payload keeps the current set. |
| `SubscribeTo` / `UnsubscribeFrom` | `{Channels: List<string>}`, always empty in this build | logged only |
There is no `UnsubscribeFromPlayers`.
## Handler table
These are the handlers the client registers. "Sent" names what DorkNet sends
today.
| Key | Msg | Sent by |
|---|---|---|
| `1` RelationshipChanged | `{PlayerID, RelationshipType, Muted, Ignored, Favorited}`: the recipient's own view, the same row `api/relationships/v2/get` lists | friend request / accept / remove / add / add-with-code (both players); favorite / mute / ignore and their reverses (caller only) |
| `2` MessageReceived | `{Id, FromPlayerId (Int32), SentTime, Type, Data (string), RoomId, PlayerEventId}`, the same row `api/messages/v2/get` lists | DMs and game invites; `SystemNotificationService` (cheers, friend request Type 4, accepted Type 40, "wants to join" Type 10, coach message Type 100, event invite text, room co-owner Type 62 invited / 60 added / 61 removed with `RoomId` set — the client's co-owner slots read only RoomId) |
| `3` MessageDeleted | `{Id}` | not sent |
| `5` RefreshLogin | none | not sent |
| `6` Logout | `{Reason}`: a **number**, enum Unknown 0, UserInitiated 1, SessionTakeover 2, ForciblyLoggedOut 3, Banned 4 | `NotificationService.LogoutAsync`: second-session takeover (2), account deleted or password reset (3), bans (4) |
| `20` / `ModerationQuitGame` | none | not sent |
| `22` ModerationKick | **flat** `{ReportCategory, Duration, GameSessionId, IsHostKick, Message, PlayerIdReporter, IsBan, IsVoiceModAutoban, IsWarning, VoteKickReason, TimeoutStartedAt}` | `KickPlayerAsync`: admin kick, close instance, in-game instant kick, join timeout |
| `ModerationUnkick` | same as 22 | not sent |
| `23` ModerationKickAttemptFailed | `{ReportCategory, YesVotes, NoVotes, PlayerIdReported}` | not sent |
| `25` ServerMaintenance | `{StartsInMinutes}`; 0 cancels the countdown | admin Broadcast, when a countdown is given |
| `30` / `31` GiftPackageReceived(Immediate) | the full gift package, as `api/avatar/v2/gifts` lists it (`AvatarGiftsController.ToWire`) | gifts, store gift purchases, free gifts, admin gifts |
| `50` RelationshipsInvalid | none (the client refetches) | admin "everyone is friends" toggle |
| `60` / `61` / `62` StorefrontBalance Add / Update / Purchase | 61 `{Balance, CurrencyType, Platform}`; 62 `{BalanceAddType, Delta, Balance, Platform, CurrencyType}` | `LevelService.GrantCurrencyAsync` (61), store purchase (62) |
| `70` / `71` ConsumableMapping Added / Removed | `{Id, ConsumableItemDesc (string), Count, InitialCount, CreatedAt, ActiveDurationMinutes, IsActive, IsTransferable}` | transfer (70 to the recipient), consume (always 71, with the remaining Count) |
| `80` / `81` PlayerEvent Created / Updated | the full player event, as `api/playerevents/v2/{id}` returns it | invites (80 to each invitee); every edit route and the broadcast setting (81 to attendees) |
| `82` / `84` PlayerEvent Deleted / ResponseDeleted | `{PlayerEventId}` | event delete (82 to attendees), RSVP removed (84 to the creator) |
| `83` PlayerEventResponseChanged | `{PlayerEvent, PlayerEventResponse}` | RSVP (to the creator) |
| `100` InventionModerationStateChanged | none | not sent |
| `AccountUpdate` / `SelfAccountUpdate` | the public / self account DTOs the account REST routes return | every profile edit, including profile image (`NotificationService.AccountChanged`) |
| `PresenceUpdate` | `{playerId, statusVisibility, deviceClass, vrMovementMode, roomInstance, isOnline, appVersion (string)}` | `PresenceBroadcastService`, to friends and subscribers, **never to the subject** (a frame about the local player moves them) |
| `RoomInstanceUpdate` | the room instance (`RoomInstanceDto`) | admin force-join / instance pull. It only refreshes the cached instance; the move itself comes from the next heartbeat. |
| `RoomUpdate` | the **full room** (`FGCPNAACHIK`, `RoomsController.BuildRoomServerDetails`), not the 2020 `{Room, Scenes}` wrapper | admin role / owner changes and every in-game role change, invite, accept, reject or removal (everyone in the room plus the changed player and the actor), admin force-join |
| `PhotonAccessToken` | `ALIIIAGAPND {RoomInstanceId, PhotonAccessToken (""), Permissions: [{Permission, Role, Override, Type, Value}]}`, the same body `GET photon_access_token` returns (handler IBEOONPEELF.txt:519, `Action<ALIIIAGAPND>`) | every `PUT rooms/{id}/subrooms/{sub}/permissions`, to each player in that sub-room with their own RoomInstanceId |
| `ChatMessageReceived` | `{chatMessageId, chatThreadId, senderPlayerId, timeSent, contents}` | chat sends, to every member including the sender |
| `PlayerLeftChat` | the same chat-message shape; only `chatThreadId` and `senderPlayerId` (the leaver) are read | leaving a group chat |
| `PlayerProgressionLevelUpdate` | `{PlayerId, Level, XP}`, as `api/players/v2/progression` returns it | every XP award (`LevelService.AwardXpAsync`) |
| `CommunityBoardUpdate` | community board | community board edits |
| `ReputationUpdate`, `RoomCurrency*`, `KeepsakeInstance*`, `GoToFailure`, `AnnouncementUpdate/Delete`, `ClubMembershipUpdate`, `GameConfig.Refresh`, `PlayerSettings.Refresh`, … | see the contract notes in `recroom-2023-client-api-complete.md` | not sent |
**Not registered at all** (a push with these keys is dropped): numeric 4, 11,
12, 13, 15, 21, 24 (ModerationRoomBan), 40, 85, 90, 95.
## Kicks versus logouts
A ModerationKick only sends the player to their dorm, still signed in.
The kick handler builds the dialog text like this:
- It always starts from its own "Kicked to Dormroom".
- It appends "Reason: …" from `VoteKickReason` only when `ReportCategory`
is 10 and `IsHostKick` is false. `Message` is never shown.
- When `GameSessionId` is the kicked player's current room instance, it also
announces the kick to that room.
- A `Duration` above 0 starts a timeout follow-up.
Anything that must sign the player out uses Logout instead: bans, account
deletion, password resets and second-session takeover. The ban check then
refuses the banned player's next login.
## Admin broadcast
`POST api/admin/v1/broadcast` `{Message?, StartsInMinutes?}`:
- `Message` becomes a coach message (MessageReceived Type 100 from the
system account) in every online player's notifications.
- `StartsInMinutes` starts the client's own maintenance countdown (25).
ServerMaintenance has no text field. The old `{Message}` payload was never
shown, and it cancelled any running countdown.
## Not yet done
- **No offline queue.** A push sent while the player has no connection is lost.
Messages are stored, so they still appear in `api/messages/v2/get`.
RecFlare keeps the newest 500 per player and flushes them on connect.
- The 2020 save and restore routes (`api/rooms/v4/saveData`,
`…/restoredata`, `api/rooms/v1/datahistory/restore`) still push the 2020
`RoomUpdate` wrapper and a self PresenceUpdate. The 2023 client never
calls them; its save (`subrooms/{n}/data`) and publish routes push
nothing and return the room in the HTTP reply.
- `sendfriendintroductions` stores Type 30 text instead of FriendIntroduction
(130), and pushes nothing. The format of the 130 `Data` field is unknown.
-157
View File
@@ -1,157 +0,0 @@
<#
.SYNOPSIS
Builds the per-version dorknet-clientpatch-{VersionKey}.zip the
DorkNet launcher expects to find as a GitHub Release asset.
.DESCRIPTION
Output zip layout (see launcher/RELEASES.md on the public main
branch for the full contract):
manifest.json (schema v1, describes the install)
MelonLoader.zip (downloaded from melonloader github)
DorkNet.ClientMod.dll (built from this repo)
dorknet-clientmod.json.template (copied from DorkNet.ClientMod/)
The launcher reads manifest.json, unzips MelonLoader on top of the
user's Rec Room install root, copies the DLL into Mods/, then
renders the config template into UserData/.
Self-contained: downloads MelonLoader before the dotnet build and
feeds its DLLs into the build as references, so no local Rec Room
install is required to compile DorkNet.ClientMod. (The mod resolves
every game-side type at runtime via AccessTools.TypeByName.)
.PARAMETER VersionKey
The version key matching versions.json on the public main branch
(e.g. december_2020_12_18 or march_2020_03_10).
.PARAMETER MelonLoaderVersion
MelonLoader release tag to bundle. Defaults to the 0.6.x stable
target — that's the loader DorkNet.ClientMod compiled against.
.PARAMETER OutputDir
Where the final zip lands. Defaults to repo-root/dist.
.EXAMPLE
.\tools\build-patcher-zip.ps1 -VersionKey december_2020_12_18
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$VersionKey,
[string]$MelonLoaderVersion = "v0.6.6",
[string]$OutputDir = $null
)
$ErrorActionPreference = 'Stop'
$repoRoot = Resolve-Path "$PSScriptRoot\.."
if (-not $OutputDir) { $OutputDir = Join-Path $repoRoot 'dist' }
New-Item -ItemType Directory -Force $OutputDir | Out-Null
$stage = Join-Path $env:TEMP "dorknet-patcher-$VersionKey-$([guid]::NewGuid().Guid.Substring(0,8))"
New-Item -ItemType Directory -Force $stage | Out-Null
try {
# ── 1. Fetch MelonLoader from upstream releases ──────────────────────
# We download it first so the DLLs inside can serve as compile-time
# references for DorkNet.ClientMod — keeps the build self-contained
# (no local Rec Room install required to compile the mod).
# x64 .NET 6 build targets 2020-era Rec Room. The asset name pattern
# is stable across 0.6.x: MelonLoader.x64.zip.
$mlAsset = "MelonLoader.x64.zip"
$mlUrl = "https://github.com/LavaGang/MelonLoader/releases/download/$MelonLoaderVersion/$mlAsset"
$mlZip = "$stage\MelonLoader.zip"
$mlExtract = "$stage\melonloader-extracted"
Write-Host "[fetch] downloading $mlUrl"
Invoke-WebRequest -Uri $mlUrl -OutFile $mlZip
if (-not (Test-Path $mlZip) -or (Get-Item $mlZip).Length -lt 1MB) {
throw "MelonLoader download failed or produced an unexpectedly small file"
}
Expand-Archive -Path $mlZip -DestinationPath $mlExtract -Force
# The 0.6.x release zip layout has a top-level `MelonLoader` folder
# containing net6, net35, Dependencies, Documentation. Point the
# csproj at that inner folder.
$mlRefRoot = Join-Path $mlExtract 'MelonLoader'
if (-not (Test-Path "$mlRefRoot\net6\MelonLoader.dll")) {
throw "Extracted MelonLoader is missing net6\MelonLoader.dll — release layout may have changed"
}
# ── 2. Build DorkNet.ClientMod (no Rec Room install needed) ─────────
Write-Host "[build] dotnet build DorkNet.ClientMod (Release)"
dotnet build "$repoRoot\DorkNet.ClientMod\DorkNet.ClientMod.csproj" `
-c Release --nologo `
"-p:MelonLoaderDir=$mlRefRoot" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "ClientMod build failed" }
$dll = Get-ChildItem "$repoRoot\DorkNet.ClientMod\bin\Release" -Recurse `
-Filter "DorkNet.ClientMod.dll" |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $dll) { throw "DorkNet.ClientMod.dll not found post-build" }
Copy-Item $dll.FullName "$stage\DorkNet.ClientMod.dll"
Write-Host "[build] copied $($dll.FullName)"
# ── 3. Copy the config template ──────────────────────────────────────
$template = "$repoRoot\DorkNet.ClientMod\dorknet-clientmod.json.template"
if (-not (Test-Path $template)) {
throw "Config template missing: $template"
}
Copy-Item $template "$stage\dorknet-clientmod.json.template"
# ── 4. Copy the Steam stub (Goldberg) + appid ────────────────────────
# Replaces the Valve steam_api64.dll so the unwrapped exe can run
# without Steam loaded. App ID 471710 is Rec Room — same across all
# versions we ship for.
$stubDll = "$repoRoot\DorkNet.ClientMod\steam-stub\steam_api64.dll"
$stubAppid = "$repoRoot\DorkNet.ClientMod\steam-stub\steam_appid.txt"
if (-not (Test-Path $stubDll)) {
throw "Steam stub DLL missing: $stubDll"
}
if (-not (Test-Path $stubAppid)) {
throw "Steam appid file missing: $stubAppid"
}
Copy-Item $stubDll "$stage\steam_api64.dll"
Copy-Item $stubAppid "$stage\steam_appid.txt"
# ── 5. Write manifest.json ──────────────────────────────────────────
$manifest = @{
'$schema_version' = 1
'loader_archive' = 'MelonLoader.zip'
'plugin_dll' = 'DorkNet.ClientMod.dll'
'plugin_dest' = 'Mods'
'config_template' = 'dorknet-clientmod.json.template'
'config_dest' = 'MelonLoader/UserData/dorknet-clientmod.json'
'old_plugin_paths' = @(
'BepInEx/plugins/DorkNet.ClientPatch.dll'
)
'steam_stub' = @{
# 2020.12+ is an EAC build: the real Unity binary is
# RecRoom.exe (loads RecRoom_Data/Plugins/x86_64/steam_api64.dll),
# NOT Recroom_Release.exe (which is the EAC launcher wrapper
# that loads Recroom_Release_Data/Plugins/steam_api64.dll).
# Patching the wrapper's DLL did nothing because the
# launcher's data dir is never opened by the real game
# process — symptom was "Failed to initialize Steam
# platform" because the unpatched real Steam DLL in
# RecRoom_Data tried to handshake.
'api_dll' = 'steam_api64.dll'
'api_dest' = 'RecRoom_Data/Plugins/x86_64/steam_api64.dll'
'api_backup_suffix' = '.steam-original'
'appid_file' = 'steam_appid.txt'
'appid_dest' = 'steam_appid.txt'
}
} | ConvertTo-Json -Depth 4
Set-Content -LiteralPath "$stage\manifest.json" -Value $manifest
# ── 6. Bundle into the final zip ────────────────────────────────────
# Strip the extracted MelonLoader scratch dir before zipping so it
# doesn't bloat the artifact. The MelonLoader.zip itself stays.
Remove-Item -Recurse -Force $mlExtract
$outZip = Join-Path $OutputDir "dorknet-clientpatch-$VersionKey.zip"
if (Test-Path $outZip) { Remove-Item $outZip -Force }
Compress-Archive -Path "$stage\*" -DestinationPath $outZip
$sizeMb = [math]::Round((Get-Item $outZip).Length / 1MB, 1)
Write-Host ""
Write-Host "Built $outZip ($sizeMb MB)" -ForegroundColor Green
Write-Host " Upload this to the v1-* GitHub Release on this branch."
}
finally {
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
}
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Build the DorkNet PC client packages.
patcher dorknet-clientpatch-<VersionKey>.zip, what the DorkNet launcher
installs: MelonLoader.zip, DorkNet.ClientMod.dll (built from this
repo), the config template, the Goldberg Steam stub and a
manifest.json saying where each piece goes. No game files.
client a ready-to-run client: a stock Rec Room build with a patcher zip
applied the way the launcher applies it, plus your config. It
contains the game's own files, so keep it private.
Runs on Windows, Linux and macOS. `patcher` needs the .NET SDK (the mod
builds against MelonLoader's own DLLs, so no Rec Room install is needed).
py -3 tools/build_pc.py patcher --version-key march_2023_03_21
py -3 tools/build_pc.py client --base-client RecRoom-2023-03-21.zip \\
--patcher-zip dist/dorknet-clientpatch-march_2023_03_21.zip \\
--config dorknet-clientmod.json
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from typing import NoReturn
REPO = Path(__file__).resolve().parent.parent
CLIENT_MOD = REPO / "DorkNet.ClientMod"
# Where each build expects the mod's config and the Steam DLL the game actually
# loads (see the per-branch build layout notes in the README):
# 2020.03 Recroom_Release.exe is the game; Steam DLL in its Data/Plugins.
# 2020.12 RecRoom.exe is the game (Recroom_Release.exe is the EAC wrapper);
# MelonLoader keeps UserData under MelonLoader/.
# 2023.03 RecRoom.exe + RecRoom_Data; MelonLoader 0.6 keeps UserData at the
# install root.
LAYOUTS = {
"march_2020_03_10": ("MelonLoader/UserData/dorknet-clientmod.json", "Recroom_Release_Data/Plugins/steam_api64.dll"),
"december_2020_12_18": ("MelonLoader/UserData/dorknet-clientmod.json", "RecRoom_Data/Plugins/x86_64/steam_api64.dll"),
"march_2023_03_21": ("UserData/dorknet-clientmod.json", "RecRoom_Data/Plugins/x86_64/steam_api64.dll"),
}
def step(msg: str) -> None:
print(f"==> {msg}", flush=True)
def fail(msg: str) -> NoReturn:
raise SystemExit(f"error: {msg}")
def need(path: Path | str, what: str) -> Path:
p = Path(path)
if not p.exists():
fail(f"{what} not found: {p}")
return p
def zip_dir(src: Path, out: Path) -> None:
"""Zip the contents of src (not src itself) into out."""
out.parent.mkdir(parents=True, exist_ok=True)
out.unlink(missing_ok=True)
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as z:
for f in sorted(src.rglob("*")):
if f.is_file():
z.write(f, f.relative_to(src).as_posix())
# ---- patcher --------------------------------------------------------------
def cmd_patcher(a: argparse.Namespace) -> None:
if a.version_key not in LAYOUTS:
fail(f"unknown version key '{a.version_key}'. Known: {', '.join(LAYOUTS)}")
config_dest, steam_dest = LAYOUTS[a.version_key]
out_dir = Path(a.output_dir) if a.output_dir else REPO / "dist"
with tempfile.TemporaryDirectory(prefix=f"dorknet-patcher-{a.version_key}-") as tmp:
stage = Path(tmp) / "stage"
stage.mkdir()
# 1. MelonLoader. Downloaded first so its DLLs can serve as the mod's
# compile-time references: no local Rec Room install needed.
url = f"https://github.com/LavaGang/MelonLoader/releases/download/{a.melonloader_version}/MelonLoader.x64.zip"
ml_zip = stage / "MelonLoader.zip"
step(f"downloading {url}")
with urllib.request.urlopen(url) as r, open(ml_zip, "wb") as f:
shutil.copyfileobj(r, f)
if ml_zip.stat().st_size < 1 << 20:
fail("MelonLoader download failed or produced an unexpectedly small file")
ml_extract = Path(tmp) / "melonloader"
with zipfile.ZipFile(ml_zip) as z:
z.extractall(ml_extract)
# The 0.6.x release zip has a top-level MelonLoader/ folder holding
# net6, net35, Dependencies, Documentation.
ml_ref = ml_extract / "MelonLoader"
if not (ml_ref / "net6" / "MelonLoader.dll").exists():
fail("extracted MelonLoader is missing net6/MelonLoader.dll; the release layout may have changed")
# 2. the mod
step("dotnet build DorkNet.ClientMod (Release)")
r = subprocess.run(["dotnet", "build", str(CLIENT_MOD / "DorkNet.ClientMod.csproj"),
"-c", "Release", "--nologo", f"-p:MelonLoaderDir={ml_ref}"])
if r.returncode:
fail("ClientMod build failed")
dlls = sorted((CLIENT_MOD / "bin" / "Release").rglob("DorkNet.ClientMod.dll"),
key=lambda p: p.stat().st_mtime, reverse=True)
if not dlls:
fail("DorkNet.ClientMod.dll not found after the build")
shutil.copy2(dlls[0], stage / "DorkNet.ClientMod.dll")
print(f" built {dlls[0]}")
# 3. config template + 4. Goldberg Steam stub (App ID 471710, the same
# for every build). The stub replaces Valve's steam_api64.dll so the
# game runs without Steam.
for src, what in ((CLIENT_MOD / "dorknet-clientmod.json.template", "config template"),
(CLIENT_MOD / "steam-stub" / "steam_api64.dll", "Steam stub DLL"),
(CLIENT_MOD / "steam-stub" / "steam_appid.txt", "Steam appid file")):
shutil.copy2(need(src, what), stage / src.name)
# 5. manifest.json, read by the launcher and by `client` below.
manifest = {
"$schema_version": 1,
"loader_archive": "MelonLoader.zip",
"plugin_dll": "DorkNet.ClientMod.dll",
"plugin_dest": "Mods",
"config_template": "dorknet-clientmod.json.template",
"config_dest": config_dest,
"old_plugin_paths": ["BepInEx/plugins/DorkNet.ClientPatch.dll"],
"steam_stub": {
# 2020.12+ is an EAC build: the real Unity binary is RecRoom.exe
# (it loads RecRoom_Data/Plugins/x86_64/steam_api64.dll), NOT
# Recroom_Release.exe (the EAC launcher wrapper, whose data dir
# the real game never opens).
"api_dll": "steam_api64.dll",
"api_dest": steam_dest,
"api_backup_suffix": ".steam-original",
"appid_file": "steam_appid.txt",
"appid_dest": "steam_appid.txt",
},
}
(stage / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
# 6. bundle
out = out_dir / f"dorknet-clientpatch-{a.version_key}.zip"
zip_dir(stage, out)
print()
print(f"Built {out} ({out.stat().st_size / 2**20:.1f} MB)")
# ---- client ---------------------------------------------------------------
def cmd_client(a: argparse.Namespace) -> None:
base = need(a.base_client, "stock client")
patcher_zip = need(a.patcher_zip, "patcher zip")
config = need(a.config, "client config") if a.config else None
with tempfile.TemporaryDirectory(prefix="dorknet-pc-client-") as tmp:
tmp = Path(tmp)
install, patcher = tmp / "install", tmp / "patcher"
patcher.mkdir()
# 1. stock client: a zip or a folder, the install at its root or
# inside one wrapping folder.
step(f"unpacking stock client {base}")
if base.is_dir():
unpacked = base
else:
unpacked = tmp / "base"
with zipfile.ZipFile(base) as z:
z.extractall(unpacked)
root = unpacked
top = list(root.iterdir())
if len(top) == 1 and top[0].is_dir():
root = top[0]
games = [p.name for p in root.glob("*.exe") if (root / f"{p.stem}_Data").is_dir()]
if not games:
fail(f"no game exe with a matching _Data folder at the root of {base}")
print(f" game: {', '.join(games)}")
shutil.copytree(root, install, ignore=shutil.ignore_patterns(".DepotDownloader"))
# 2. apply the patcher the way the launcher does, from its manifest.
step(f"applying {patcher_zip}")
with zipfile.ZipFile(patcher_zip) as z:
z.extractall(patcher)
m = json.loads(need(patcher / "manifest.json", "patcher manifest.json").read_text(encoding="utf-8"))
with zipfile.ZipFile(need(patcher / m["loader_archive"], "MelonLoader archive")) as z:
z.extractall(install)
print(" MelonLoader -> install root")
plugin_dir = install / m["plugin_dest"]
plugin_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(patcher / m["plugin_dll"], plugin_dir / m["plugin_dll"])
print(f" {m['plugin_dll']} -> {m['plugin_dest']}/")
config_dest = install / m["config_dest"]
config_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(config or patcher / m["config_template"], config_dest)
print(f" config -> {m['config_dest']}{'' if config else ' (template, unedited)'}")
stub = m.get("steam_stub")
if stub:
api_dest = install / stub["api_dest"]
if api_dest.exists():
backup = api_dest.with_name(api_dest.name + stub["api_backup_suffix"])
if not backup.exists():
shutil.copy2(api_dest, backup)
api_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(patcher / stub["api_dll"], api_dest)
shutil.copy2(patcher / stub["appid_file"], install / stub["appid_dest"])
print(f" Steam stub -> {stub['api_dest']}")
for old in m.get("old_plugin_paths") or []:
p = install / old
if p.exists():
p.unlink()
print(f" removed old {old}")
# 3. zip
key = patcher_zip.stem.removeprefix("dorknet-clientpatch-")
out = Path(a.output_zip) if a.output_zip else REPO / "dist" / f"dorknet-client-{key}.zip"
step(f"zipping -> {out}")
zip_dir(install, out)
print()
print(f"Built {out} ({out.stat().st_size / 2**20:.1f} MB). It contains game files: keep it private.")
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description="Build the DorkNet PC client packages.")
sub = p.add_subparsers(dest="cmd", required=True)
pz = sub.add_parser("patcher", help="dorknet-clientpatch-<VersionKey>.zip (no game files)")
pz.add_argument("--version-key", required=True, choices=sorted(LAYOUTS),
help="the build the zip is for; picks the install layout")
pz.add_argument("--melonloader-version", default="v0.6.6",
help="MelonLoader release tag to bundle (default: v0.6.6, what the mod compiles against)")
pz.add_argument("--output-dir", help="where the zip goes (default: <repo>/dist)")
pz.set_defaults(func=cmd_patcher)
c = sub.add_parser("client", help="stock build + patcher zip + config -> ready-to-run client zip")
c.add_argument("--base-client", required=True, help="the stock client, as a .zip or a folder")
c.add_argument("--patcher-zip", required=True, help="dorknet-clientpatch-<VersionKey>.zip from `patcher`")
c.add_argument("--config", help="dorknet-clientmod.json to install (default: the unedited template)")
c.add_argument("--output-zip", help="default: <repo>/dist/dorknet-client-<VersionKey>.zip")
c.set_defaults(func=cmd_client)
a = p.parse_args()
a.func(a)
if __name__ == "__main__":
main()