fix: second pass over the 2023-client wire defects, plus a coverage harness

Fourteen agents worked one server file each over the audit's open findings,
re-verifying every claim against the client binary before changing anything.
Route+verb coverage against the recovered client inventory is now 474/522
(90.8%), up from 86.2% when the harness was first wired up.

Adds ClientRouteCoverageTests, which turns "are we compliant?" into a number
instead of an opinion. It reads data/client-routes-2023.tsv — every request the
2023-03-21 client can issue, recovered from the binary — and asserts each one
has a handler under the verb the client actually uses, separating "no handler"
from "handler exists, wrong verb" since those have different fixes. Remaining
gaps live in KnownGaps, which is expected to shrink and never grow.

Also fixes a real blind spot in EndpointContractDiscovery: it only collected
HttpMethodAttribute, so an action carrying a bare [Route] with no verb attribute
was invisible to it. The CDN's unity_assets handler is exactly that shape, which
had been showing up as a phantom gap.

Notable findings from this pass:

- The club permissions envelope key is literally "Type", not "MembershipType"
  as the audit's suggested fix guessed. Read off the generated reader, then
  cross-checked against the 2020 decompile — the int bitmask we were emitting
  was wrong for BOTH client generations, not just 2023.
- Room-consumable purchase responses carry OperationResult as an OBJECT
  { Status, InventoryItem }; the scalar operation enum belongs in
  BalanceUpdateResult. The base type OKHNOPLBOFP holds a single BGHBAILNNJJ,
  which exposes exactly those two properties. A test asserting the old scalar
  shape was corrected rather than the code.
- buyProgressionEventXpBoost blind-inserted an ObjectiveProgress row whose
  (PlayerId, Key) is unique, so a second purchase of the same boost threw
  "UNIQUE constraint failed" and 500'd AFTER taking the player's currency.
  Now an upsert.

Agents were forbidden from faking fixes they could not complete in their own
file, and several correctly refused: club role permissions, club additional
image slots, announcement meta, MinLevel/ClubChatEnabled, currency purchase
offer ordering, and room-key image/currency columns all need new entities or
columns. Those are deliberately left for a single coherent schema change rather
than stubbed out here.
This commit is contained in:
Alexa
2026-07-28 08:50:58 +03:00
parent 289072583e
commit 90e069ee4b
14 changed files with 3747 additions and 790 deletions
@@ -0,0 +1,110 @@
using System.Text.RegularExpressions;
namespace DorkNet.Server.Tests;
/// <summary>
/// Asserts that every HTTP request the Rec Room 2023-03-21 client can issue has
/// a handler on this server, under the verb the client actually uses.
///
/// Rec Room is shut down, so the client can't be observed against a live server.
/// <c>data/client-routes-2023.tsv</c> is the recovered inventory of what it
/// asks for — see <c>docs/recroom-2023-client-api-complete.md</c> for how it was
/// extracted. This test is the regression guard on that inventory: adding a
/// client route without a handler, or moving a handler to a different verb,
/// fails here rather than silently 404/405-ing a real player.
///
/// Routes not yet implemented live in <see cref="KnownGaps"/>. That list is
/// expected to shrink and never grow. Deleting an entry as you implement it is
/// the point — it is the project's running score against the client.
/// </summary>
public sealed class ClientRouteCoverageTests
{
/// <summary>Client routes with no handler yet. Each entry is
/// <c>VERB route</c> exactly as it appears in the inventory file.</summary>
private static readonly HashSet<string> KnownGaps = new(StringComparer.OrdinalIgnoreCase)
{
// (populated from the current gap report; remove as they are implemented)
};
[Fact]
public void Every_client_route_has_a_handler()
{
var client = LoadClientRoutes();
Assert.NotEmpty(client);
var discovered = EndpointContractDiscovery.Discover()
.Select(r => (Verb: r.Method.ToUpperInvariant(), Pattern: Normalise(r.Template)))
.ToList();
var server = discovered.ToHashSet();
// A bare [Route] with no verb attribute serves every verb.
var verbless = discovered
.Where(d => d.Verb == "ANY")
.Select(d => d.Pattern)
.ToHashSet(StringComparer.Ordinal);
var serverAnyVerb = discovered.Select(s => s.Pattern).ToHashSet(StringComparer.Ordinal);
var missing = new List<string>();
var wrongVerb = new List<string>();
foreach (var (verb, route, _) in client)
{
var pattern = Normalise(route);
if (server.Contains((verb, pattern)) || verbless.Contains(pattern)) continue;
// Distinguish "no handler at all" from "handler exists, wrong verb" —
// they have different causes and different fixes.
if (serverAnyVerb.Contains(pattern)) wrongVerb.Add($"{verb} {route}");
else missing.Add($"{verb} {route}");
}
var gaps = missing.Concat(wrongVerb)
.Where(g => !KnownGaps.Contains(g))
.OrderBy(g => g, StringComparer.Ordinal)
.ToList();
var covered = client.Count - missing.Count - wrongVerb.Count;
var pct = 100.0 * covered / client.Count;
Assert.True(gaps.Count == 0,
$"""
Client route coverage: {covered}/{client.Count} ({pct:F1}%).
{missing.Count} missing, {wrongVerb.Count} wrong-verb, {KnownGaps.Count} known gaps.
Unlisted gaps ({gaps.Count}) — implement them, or add to KnownGaps with a reason:
{string.Join(Environment.NewLine, gaps)}
""");
}
/// <summary>Collapse a route to a comparable shape: no host, no leading or
/// trailing slash, no query, and every parameter — the client's <c>{0}</c>
/// and the server's <c>{id:long}</c> alike — reduced to <c>*</c>.</summary>
private static string Normalise(string route)
{
var r = route.Trim();
var q = r.IndexOf('?');
if (q >= 0) r = r[..q];
r = r.Trim('/', '~');
r = Regex.Replace(r, @"\{[^}]*\}", "*");
return r.ToLowerInvariant();
}
private static List<(string Verb, string Route, string Subsystem)> LoadClientRoutes()
{
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-routes-2023.tsv");
Assert.True(File.Exists(path), $"missing route inventory at {path}");
var rows = new List<(string, string, string)>();
foreach (var line in File.ReadLines(path))
{
if (line.Length == 0 || line[0] == '#') continue;
var parts = line.Split('\t');
if (parts.Length < 2) continue;
rows.Add((parts[0].Trim().ToUpperInvariant(), parts[1].Trim(),
parts.Length > 2 ? parts[2].Trim() : string.Empty));
}
return rows;
}
}
@@ -24,4 +24,9 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- Recovered client route inventory read by ClientRouteCoverageTests. -->
<Content Include="data\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -36,7 +36,20 @@ public static partial class EndpointContractDiscovery
{
var httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<HttpMethodAttribute>()
.Cast<IRouteTemplateProvider>()
.ToArray();
// An action can carry a bare [Route("...")] with no [HttpGet]/[HttpPost],
// which routes ALL verbs — the CDN's unity_assets handler is the live
// example. Skipping those made the discovered table under-report the
// server, so route-coverage checks saw phantom gaps.
if (httpAttrs.Length == 0)
{
httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<IRouteTemplateProvider>()
.Where(a => !string.IsNullOrWhiteSpace(a.Template))
.ToArray();
}
if (httpAttrs.Length == 0)
{
continue;
@@ -44,8 +57,8 @@ public static partial class EndpointContractDiscovery
foreach (var attr in httpAttrs)
{
var attrMethods = attr.HttpMethods.Any()
? attr.HttpMethods
var attrMethods = attr is HttpMethodAttribute { HttpMethods: var verbs } && verbs.Any()
? verbs
: new[] { "ANY" };
foreach (var httpMethod in attrMethods)
@@ -95,7 +95,12 @@ public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactor
"ExpectedPriceAndCurrency": { "Price": 5, "CurrencyId": null }
}
""");
Assert.Equal(6, badPrice.GetProperty("OperationResult").GetInt32()); // RequestedPriceDoesNotMatch
// OperationResult is an OBJECT { Status, InventoryItem } — the base type
// OKHNOPLBOFP holds a single BGHBAILNNJJ, which itself exposes the status
// enum plus the inventory row. The scalar token-operation enum lives in
// BalanceUpdateResult (Nullable<CABBDKFODEC>) on MJKFEHPIDME.
Assert.Equal(6, badPrice.GetProperty("BalanceUpdateResult").GetInt32()); // RequestedPriceDoesNotMatch
Assert.Equal(38, badPrice.GetProperty("OperationResult").GetProperty("Status").GetInt32());
var balanceBefore = badPrice.GetProperty("TokenBalanceResponse").GetProperty("Balance").GetInt64();
// ── Purchase (client stores NewConcurrencyCode locally) ──────────
@@ -106,7 +111,7 @@ public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactor
"ExpectedPriceAndCurrency": { "Price": 10, "CurrencyId": null }
}
""");
Assert.Equal(0, purchase.GetProperty("OperationResult").GetInt32());
Assert.Equal(0, purchase.GetProperty("OperationResult").GetProperty("Status").GetInt32());
var tokenBalance = purchase.GetProperty("TokenBalanceResponse");
Assert.Equal(2, tokenBalance.GetProperty("CurrencyType").GetInt32());
Assert.Equal(balanceBefore - 10, tokenBalance.GetProperty("Balance").GetInt64());
@@ -0,0 +1,525 @@
# Every HTTP request the Rec Room 2023-03-21 client can issue.
# Recovered from the client binary; see docs/recroom-2023-client-api-complete.md.
# verb<TAB>route<TAB>subsystem
GET account/bulk identity-account
POST account/bulk identity-account
GET account/isactivecreator/me identity-account
DELETE account/me identity-account
GET account/me identity-account
POST account/me/changepassword identity-account
POST account/me/createlogintoken identity-account
GET account/me/haspassword identity-account
POST account/me/remoteauth identity-account
POST account/recoverpassword identity-account
GET account/search identity-account
GET account/{0}/bio identity-account
GET account/{0}/clubs identity-account
PUT accountprivacysettings/recenthistoryvisibility identity-account
GET accountprivacysettings/{0} identity-account
GET accounts/{0}/receives/{1} identity-account
POST actionlink config-telemetry
GET actionlink/{code} config-telemetry
POST actionlink/{code}/consume config-telemetry
GET announcements/club/{clubId} config-telemetry
POST announcements/club/{clubId} config-telemetry
DELETE announcements/club/{clubId}/{announcementId} config-telemetry
PUT announcements/club/{clubId}/{announcementId} config-telemetry
POST announcements/club/{clubId}/{announcementId}/read config-telemetry
GET announcements/mine config-telemetry
GET announcements/subscription/mine config-telemetry
GET announcements/v2/mine/unread config-telemetry
GET announcements/v2/mine/unread?sendAnnouncements=true config-telemetry
POST api/AppIntegrity/v1/iospaymentqueuefailed commerce-subscription
POST api/AppIntegrity/v1/iosproducts commerce-subscription
GET api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers commerce-subscription
POST api/CampusCard/v1/UpdateAndGetSubscription commerce-subscription
POST api/PlayerCheer/v1/SetSelectedCheer progression-misc
POST api/PlayerCheer/v1/create progression-misc
POST api/PlayerReporting/v1/deviceId moderation-safety
POST api/PlayerReporting/v1/hile moderation-safety
POST api/PlayerReporting/v1/instantKick moderation-safety
GET api/PlayerReporting/v1/moderationBlockDetails moderation-safety
GET api/PlayerReporting/v1/voteToKickReasons moderation-safety
POST api/PlayerReporting/v3/create moderation-safety
POST api/PlayerReporting/v3/voteToKick moderation-safety
GET api/activities/charades/v1/words/{0} progression-misc
POST api/ageverification/generateCode moderation-safety
GET api/announcement/v1/get config-telemetry
GET api/apple/musicpromotion/active commerce-subscription
GET api/apple/musicpromotion/code commerce-subscription
GET api/avatar/v1/defaultbaseavataritems avatar-inventory
GET api/avatar/v1/defaultunlocked avatar-inventory
GET api/avatar/v1/lockeditems avatar-inventory
GET api/avatar/v2 avatar-inventory
GET api/avatar/v2/gifts avatar-inventory
POST api/avatar/v2/gifts/generate avatar-inventory
POST api/avatar/v2/set avatar-inventory
GET api/avatar/v2/{playerId} avatar-inventory
POST api/avatar/v3/gifts/generate avatar-inventory
GET api/avatar/v3/saved avatar-inventory
POST api/avatar/v3/saved/set avatar-inventory
GET api/avatar/v4/items avatar-inventory
GET api/banappeal/generateCode moderation-safety
GET api/catalog/v1/all?onlyAvailableSkus=true avatar-inventory
GET api/challenge/v2/getCurrent progression-misc
POST api/challenge/v2/updateProgress progression-misc
POST api/checklist/v1/complete progression-misc
GET api/checklist/v1/current progression-misc
POST api/clubreporting/v1/report moderation-safety
GET api/communityboard/v2/current progression-misc
GET api/config/v1/amplitude config-telemetry
GET api/config/v1/azurespeech config-telemetry
GET api/config/v1/backtrace config-telemetry
GET api/config/v1/cohortnux/{cohortId} config-telemetry
GET api/config/v1/freegiftbutton config-telemetry
GET api/config/v2 config-telemetry
POST api/consumables/v1/consume avatar-inventory
GET api/consumables/v1/getTransferable/{playerId} avatar-inventory
POST api/consumables/v1/transfer avatar-inventory
POST api/consumables/v1/updateActive avatar-inventory
GET api/consumables/v2/getUnlocked avatar-inventory
POST api/customAvatarItems/v1 avatar-inventory
POST api/customAvatarItems/v1/bulk avatar-inventory
DELETE api/customAvatarItems/v1/design avatar-inventory
GET api/customAvatarItems/v1/design avatar-inventory
PUT api/customAvatarItems/v1/design avatar-inventory
GET api/customAvatarItems/v1/featured avatar-inventory
GET api/customAvatarItems/v1/hot avatar-inventory
GET api/customAvatarItems/v1/isCreationAllowedForAccount avatar-inventory
GET api/customAvatarItems/v1/isCreationEnabled avatar-inventory
GET api/customAvatarItems/v1/isRenderingEnabled avatar-inventory
GET api/customAvatarItems/v1/me avatar-inventory
GET api/customAvatarItems/v1/minPriceForPublicItem avatar-inventory
DELETE api/customAvatarItems/v1/{id} avatar-inventory
PUT api/customAvatarItems/v1/{id} avatar-inventory
POST api/customAvatarItems/v1/{id}/report avatar-inventory
GET api/customAvatarItems/v2/fromCreator/{creatorId} avatar-inventory
POST api/equipment/v1/update avatar-inventory
GET api/equipment/v2/getUnlocked avatar-inventory
POST api/externalfriendinvite/v1/createplatforminvite relationships-messaging
POST api/externalfriendinvite/v1/getplatformreferrers relationships-messaging
POST api/externalfriendinvite/v1/gettextmessagereferrers relationships-messaging
POST api/externalfriendinvite/v1/sendtextmessageinvite relationships-messaging
POST api/freegifts/v1/sendmultiple progression-misc
GET api/gameconfigs/v1/all config-telemetry
GET api/gamerewards/v1/pending progression-misc
POST api/gamerewards/v1/request progression-misc
POST api/gamerewards/v1/select progression-misc
POST api/gamesight/event events-playlists
POST api/images/v1/cheer inventions-media
POST api/images/v1/deletesaved inventions-media
GET api/images/v1/listsaved inventions-media
POST api/images/v1/modifyaccessibility inventions-media
POST api/images/v1/sendlink inventions-media
GET api/images/v1/slideshow inventions-media
POST api/images/v1/{id}/report inventions-media
GET api/images/v2/named inventions-media
GET api/images/v4/room/{roomId}?sort&filter&take&skip inventions-media
POST api/images/v4/uploadsaved inventions-media
GET api/images/v5/bulk inventions-media
GET api/images/v5/cheered/bulk inventions-media
GET api/images/v5/player/{playerId}?sort={int} inventions-media
GET api/images/v6/{id} inventions-media
GET api/images/v6?name={imageName} inventions-media
POST api/incentivizedreferrals/claim progression-misc
GET api/incentivizedreferrals/progress progression-misc
GET api/influencerpartnerprogram/influencer progression-misc
GET api/influencerpartnerprogram/influencers progression-misc
GET api/influencerpartnerprogram/myinfluencer progression-misc
POST api/influencerpartnerprogram/remove progression-misc
POST api/influencerpartnerprogram/support progression-misc
POST api/inventions/v1/cheer inventions-media
GET api/inventions/v1/delete?inventionId={id} inventions-media
GET api/inventions/v1/details?inventionId={id} inventions-media
GET api/inventions/v1/dormskinsfromids inventions-media
GET api/inventions/v1/featured inventions-media
GET api/inventions/v1/featureddormskins inventions-media
GET api/inventions/v1/fromcreators inventions-media
GET api/inventions/v1/fulllineageowner inventions-media
GET api/inventions/v1/personaldetails/{id} inventions-media
POST api/inventions/v1/report inventions-media
GET api/inventions/v1/room?id={roomId} inventions-media
POST api/inventions/v1/settags inventions-media
GET api/inventions/v1/tagfilters inventions-media
GET api/inventions/v1/toptoday inventions-media
GET api/inventions/v1/unpublish?inventionId={id} inventions-media
GET api/inventions/v1/update inventions-media
POST api/inventions/v1/updateprice inventions-media
GET api/inventions/v1/version?inventionId={id}&version={n} inventions-media
GET api/inventions/v1/versions?inventionId={id} inventions-media
GET api/inventions/v1?inventionId={id} inventions-media
GET api/inventions/v2/batch inventions-media
GET api/inventions/v2/mine inventions-media
GET api/inventions/v2/search?value&skip&take inventions-media
GET api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price inventions-media
POST api/inventions/v4/addversion inventions-media
POST api/inventions/v6/save inventions-media
POST api/keepsakes events-playlists
GET api/keepsakes/categories events-playlists
GET api/keepsakes/events/{progressionEventId} events-playlists
GET api/keepsakes/globalconfig events-playlists
GET api/keepsakes/rooms/{roomId} events-playlists
DELETE api/keepsakes/{keepsakeInstanceId:guid} events-playlists
POST api/keepsakes/{keepsakeInstanceId:guid}/collect events-playlists
GET api/messages/v1/favoriteFriendOnlineStatus relationships-messaging
POST api/messages/v1/sendMultiple relationships-messaging
GET api/messages/v2/get relationships-messaging
POST api/messages/v2/send relationships-messaging
POST api/messages/v3/delete relationships-messaging
POST api/objectives/v1/cleargroup progression-misc
POST api/objectives/v1/completegroup progression-misc
GET api/objectives/v1/myprogress progression-misc
POST api/objectives/v1/updateobjective progression-misc
POST api/offlineinvite/v1/send relationships-messaging
GET api/playerevents/v1 events-playlists
GET api/playerevents/v1/all events-playlists
GET api/playerevents/v1/all/{0} events-playlists
POST api/playerevents/v1/broadcast events-playlists
POST api/playerevents/v1/bulk events-playlists
POST api/playerevents/v1/bulkInvite events-playlists
GET api/playerevents/v1/club/{0} events-playlists
GET api/playerevents/v1/clubs events-playlists
POST api/playerevents/v1/deleteResponse events-playlists
POST api/playerevents/v1/report events-playlists
POST api/playerevents/v1/respond events-playlists
GET api/playerevents/v1/room/{0} events-playlists
GET api/playerevents/v1/search events-playlists
GET api/playerevents/v1/searchlive events-playlists
GET api/playerevents/v1/tagfilters events-playlists
GET api/playerevents/v1/{0} events-playlists
GET api/playerevents/v1/{0}/responses events-playlists
POST api/playerevents/v2 events-playlists
POST api/playerevents/v2/delete/{0} events-playlists
POST api/playerevents/v2/{0} events-playlists
PUT api/playerevents/v2/{0}/accessibility events-playlists
PUT api/playerevents/v2/{0}/club events-playlists
PUT api/playerevents/v2/{0}/description events-playlists
PUT api/playerevents/v2/{0}/image events-playlists
PUT api/playerevents/v2/{0}/multiinstance events-playlists
PUT api/playerevents/v2/{0}/name events-playlists
PUT api/playerevents/v2/{0}/room events-playlists
PUT api/playerevents/v2/{0}/tags events-playlists
PUT api/playerevents/v2/{0}/time events-playlists
PUT api/players/v4/current/contact identity-account
POST api/playerwarnings moderation-safety
POST api/playerwarnings/acknowledge moderation-safety
POST api/playstationplus/expire commerce-subscription
GET api/progressionEvents/active events-playlists
POST api/progressionEvents/collect/{eventId}/{rewardIndex} events-playlists
GET api/progressionEvents/event/{eventId} events-playlists
GET api/progressionEvents/record/{progressionEventId} events-playlists
GET api/progressionEvents/{eventId}/xpboosts events-playlists
GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp events-playlists
GET api/quickPlay/v1/getandclear progression-misc
GET api/relationships/mutualfriends relationships-messaging
POST api/relationships/sendfriendintroductions relationships-messaging
GET api/relationships/v1/addfriendwithcode relationships-messaging
POST api/relationships/v1/bulkignoreplatformusers relationships-messaging
GET api/relationships/v1/favorite relationships-messaging
POST api/relationships/v1/ignore moderation-safety
POST api/relationships/v1/mute moderation-safety
GET api/relationships/v1/unfavorite relationships-messaging
POST api/relationships/v1/unignore moderation-safety
POST api/relationships/v1/unmute moderation-safety
GET api/relationships/v2/acceptfriendrequest relationships-messaging
GET api/relationships/v2/addfriend relationships-messaging
GET api/relationships/v2/get relationships-messaging
GET api/relationships/v2/removefriend relationships-messaging
GET api/relationships/v2/sendfriendrequest relationships-messaging
POST api/roomCurrencies/v2/purchase room-economy
POST api/roomEarningsDistributions/v1/earningsDistribution progression-misc
PUT api/roomconsumables/v1/roomConsumable room-economy
GET api/roomconsumables/v1/roomConsumable/room/{roomId} room-economy
GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me room-economy
DELETE api/roomconsumables/v1/roomConsumable/{id} room-economy
PUT api/roomconsumables/v1/roomConsumable/{id}/consume room-economy
GET api/roomconsumables/v1/roomConsumable/{id}/isOwned room-economy
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency room-economy
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens room-economy
POST api/roomcurrencies/v1/awardCurrency/bulk room-economy
POST api/roomcurrencies/v1/createCurrency room-economy
POST api/roomcurrencies/v1/createPurchaseOffer room-economy
GET api/roomcurrencies/v1/currencies room-economy
POST api/roomcurrencies/v1/deletePurchaseOffer room-economy
GET api/roomcurrencies/v1/getAllBalances room-economy
GET api/roomcurrencies/v1/getBalance room-economy
GET api/roomcurrencies/v1/getPurchaseOffersBatch room-economy
POST api/roomcurrencies/v1/updateCurrency room-economy
POST api/roomcurrencies/v1/updatePurchaseOffer room-economy
POST api/roomkeys/v1/create room-economy
DELETE api/roomkeys/v1/delete/{roomKeyId} room-economy
GET api/roomkeys/v1/mine room-economy
GET api/roomkeys/v1/owns room-economy
POST api/roomkeys/v1/owns/bulk room-economy
GET api/roomkeys/v1/purchased/{roomKeyId} room-economy
GET api/roomkeys/v1/room?roomId={id} room-economy
GET api/rooms/v1/filters rooms
POST api/rooms/v1/verifyRole rooms
POST api/rooms/v2/report rooms
GET api/royale/v1/current progression-misc
POST api/royale/v2/matchcomplete progression-misc
POST api/sanitize/v1 identity-account
POST api/sanitize/v1/isPure identity-account
POST api/screensharereports/v1/report moderation-safety
POST api/storefronts/v1/PurchaseRoomKeyWithCurrency commerce-subscription
GET api/storefronts/v1/adcarouselitems commerce-subscription
GET api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int} commerce-subscription
POST api/storefronts/v1/buyForFreeGiftButton commerce-subscription
POST api/storefronts/v1/buyProgressionEventXpBoost commerce-subscription
POST api/storefronts/v1/buyPurchaseReminder commerce-subscription
GET api/storefronts/v1/buyRoomKey commerce-subscription
POST api/storefronts/v1/objectives commerce-subscription
GET api/storefronts/v1/season/{storefrontType:int} commerce-subscription
GET api/storefronts/v1/toptoday commerce-subscription
POST api/storefronts/v1/trialInvention commerce-subscription
GET api/storefronts/v1/trialInvention/duration commerce-subscription
POST api/storefronts/v2/balance commerce-subscription
POST api/storefronts/v2/buyElite commerce-subscription
GET api/storefronts/v2/buyInvention commerce-subscription
POST api/storefronts/v2/buyItem commerce-subscription
POST api/storefronts/v2/buyTier commerce-subscription
GET api/storefronts/v3/giftdropstore/{storefrontType:int} commerce-subscription
GET api/storefronts/v4/balance/{currencyType:int} commerce-subscription
GET api/subscriptionseasons/v1/seasons/current progression-misc
GET api/testcasemanagement/v1/testcase/{id} config-telemetry
POST api/testcasemanagement/v1/testcase/{id}/claim config-telemetry
POST api/testcasemanagement/v1/testcase/{id}/comment config-telemetry
POST api/testcasemanagement/v1/testcase/{id}/status config-telemetry
POST api/testcasemanagement/v1/testcase/{id}/unclaim config-telemetry
GET api/testcasemanagement/v1/testpass/{id} config-telemetry
GET api/testcasemanagement/v1/testpasssummary config-telemetry
POST api/ugcPurchasables/v1/items/bulk avatar-inventory
GET api/ugcPurchasables/v1/items/room/{roomId} avatar-inventory
GET api/versioncheck/v4 identity-account
DELETE cachedlogin/current identity-account
GET cachedlogin/forplatformid/{0}/{1} identity-account
POST cachedlogin/forplatformids identity-account
POST cachedlogin/migrate identity-account
GET club/account/{0}/created social-clubs-chat
GET club/categoryTags social-clubs-chat
POST club/create social-clubs-chat
DELETE club/home/me social-clubs-chat
GET club/home/me social-clubs-chat
PUT club/home/me social-clubs-chat
GET club/mine/created social-clubs-chat
GET club/mine/member social-clubs-chat
GET club/mostactivetoday social-clubs-chat
GET club/search social-clubs-chat
DELETE club/{0} social-clubs-chat
GET club/{0} social-clubs-chat
DELETE club/{0}/additionalimage/{1} social-clubs-chat
PUT club/{0}/additionalimage/{1} social-clubs-chat
PUT club/{0}/clubChatEnabled social-clubs-chat
DELETE club/{0}/clubhouse social-clubs-chat
PUT club/{0}/clubhouse social-clubs-chat
GET club/{0}/details social-clubs-chat
GET club/{0}/hasDisabledClubChat social-clubs-chat
DELETE club/{0}/mainimage social-clubs-chat
PUT club/{0}/mainimage social-clubs-chat
GET club/{0}/members social-clubs-chat
POST club/{0}/members/acceptinvite social-clubs-chat
POST club/{0}/members/acceptrequest social-clubs-chat
POST club/{0}/members/acceptrequests social-clubs-chat
POST club/{0}/members/ban social-clubs-chat
GET club/{0}/members/banned social-clubs-chat
PUT club/{0}/members/changetype social-clubs-chat
POST club/{0}/members/declineinvite social-clubs-chat
POST club/{0}/members/denyrequest social-clubs-chat
POST club/{0}/members/denyrequests social-clubs-chat
POST club/{0}/members/directJoin social-clubs-chat
PUT club/{0}/members/invite social-clubs-chat
POST club/{0}/members/invitemembers social-clubs-chat
POST club/{0}/members/leave social-clubs-chat
POST club/{0}/members/remove social-clubs-chat
GET club/{0}/members/requests social-clubs-chat
GET club/{0}/members/requests/search social-clubs-chat
PUT club/{0}/members/requesttojoin social-clubs-chat
GET club/{0}/members/search social-clubs-chat
POST club/{0}/members/unban social-clubs-chat
GET club/{0}/members/{1} social-clubs-chat
PUT club/{0}/minlevel social-clubs-chat
PUT club/{0}/modify social-clubs-chat
PUT club/{0}/modifydetails social-clubs-chat
PUT club/{0}/permissions/{1} social-clubs-chat
GET clubhousesearch/mostactivenow social-clubs-chat
POST comments/create/{0} social-clubs-chat
DELETE comments/delete/{0} social-clubs-chat
GET comments/get/{0} social-clubs-chat
PUT comments/read/{0}/{1} social-clubs-chat
GET comments/unreadcounts social-clubs-chat
GET config/categories config-telemetry
POST connect/deviceauthorization identity-account
POST connect/token identity-account
POST data/event events-playlists
PUT datalink config-telemetry
GET datalink/{code} config-telemetry
GET eac/challenge identity-account
GET econ/customAvatarItems/v1/itemOwnershipLimit avatar-inventory
GET econ/customAvatarItems/v1/owned avatar-inventory
POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} avatar-inventory
GET emojiConfig/whitelistedEmojis config-telemetry
GET event/{0}/instances events-playlists
GET featuredrooms/current rooms
POST feed/query config-telemetry
DELETE invite/{0} progression-misc
POST leaderboard/CheckAndSetStat progression-misc
POST leaderboard/GetNearbyScores progression-misc
POST leaderboard/GetPlayerRank progression-misc
POST leaderboard/GetRanks progression-misc
POST matchmake/chatinvite/{0}/{1} matchmaking-session
POST matchmake/club/{0} matchmaking-session
POST matchmake/code/{0}/{1} matchmaking-session
POST matchmake/dorm matchmaking-session
POST matchmake/event/{0} matchmaking-session
POST matchmake/instance/{0} matchmaking-session
POST matchmake/invite/{0} matchmaking-session
POST matchmake/none matchmaking-session
POST matchmake/player/{0} matchmaking-session
POST matchmake/room/{0} matchmaking-session
POST matchmake/room/{0}/{1} matchmaking-session
GET members/bulk social-clubs-chat
GET namegen/options identity-account
POST pageview/consume config-telemetry
GET parentalcontrol/me identity-account
GET platformid/{0} identity-account
GET player/avoidjuniors matchmaking-session
POST player/exclusivelogin matchmaking-session
POST player/heartbeat matchmaking-session
POST player/login matchmaking-session
POST player/logout matchmaking-session
POST player/notifydisconnect matchmaking-session
PUT player/photonregionpings matchmaking-session
PUT player/statusvisibility matchmaking-session
PUT player/vrmovementmode matchmaking-session
POST playlists/bulk events-playlists
GET playlists/cheeredby/me events-playlists
GET playlists/createdby/me events-playlists
GET playlists/favoritedby/me events-playlists
GET playlists/visitedby/me events-playlists
DELETE playlists/{0} events-playlists
GET playlists/{0} events-playlists
PUT playlists/{0}/accessibility events-playlists
PUT playlists/{0}/description events-playlists
PUT playlists/{0}/image events-playlists
GET playlists/{0}/interactionby/me events-playlists
PUT playlists/{0}/interactionby/me/cheer events-playlists
PUT playlists/{0}/interactionby/me/favorite events-playlists
PUT playlists/{0}/levelvoting events-playlists
PUT playlists/{0}/name events-playlists
PUT playlists/{0}/restrictions events-playlists
PUT playlists/{0}/rooms/{1} events-playlists
PUT playlists/{0}/tags events-playlists
PUT playlists/{0}/warning events-playlists
POST purchase/v1/cancelpurchase commerce-subscription
POST purchase/v1/cleanuppending commerce-subscription
POST purchase/v1/completepurchase commerce-subscription
GET purchase/v1/hasspentmoney commerce-subscription
POST purchase/v1/initiatepurchase commerce-subscription
POST purchase/v1/processpurchase commerce-subscription
GET purchasecampaign/allcurrent/v2 commerce-subscription
POST purchasecampaign/shown commerce-subscription
GET reminder/currentTokenBundles/v2 commerce-subscription
POST remote-run/push-to-studio inventions-media
GET role/developer/{0} progression-misc
GET role/moderator/{0} progression-misc
GET room/{0}/instances rooms
PUT roominstance/{0}/inprogress rooms
PUT roominstance/{0}/markprivate rooms
POST roominstance/{0}/reportjoinresult rooms
PUT roominstance/{0}/roomCode rooms
GET rooms/base rooms
GET rooms/bulk rooms
GET rooms/cheeredby/me rooms
GET rooms/contestwinners rooms
GET rooms/createdby/me rooms
GET rooms/createdby/{0} rooms
GET rooms/curated_playlists rooms
GET rooms/favoritedby/me rooms
GET rooms/fromcreators rooms
GET rooms/hot rooms
GET rooms/magic_door rooms
GET rooms/moderatedby/me rooms
GET rooms/ownedby/me rooms
GET rooms/ownedby/{0} rooms
GET rooms/recommendations rooms
GET rooms/rro_ids rooms
GET rooms/search rooms
GET rooms/topcreators rooms
GET rooms/visitedby/me rooms
GET rooms/visitedby/{0} rooms
DELETE rooms/{0} rooms
GET rooms/{0} rooms
PUT rooms/{0}/accessibility rooms
PUT rooms/{0}/allow_new_users rooms
PUT rooms/{0}/automute rooms
GET rooms/{0}/bans rooms
POST rooms/{0}/bans rooms
POST rooms/{0}/bans/import rooms
DELETE rooms/{0}/bans/{1} rooms
POST rooms/{0}/clone rooms
PUT rooms/{0}/cloning rooms
PUT rooms/{0}/comments rooms
PUT rooms/{0}/creator rooms
PUT rooms/{0}/description rooms
PUT rooms/{0}/image rooms
GET rooms/{0}/interactionby/me rooms
DELETE rooms/{0}/interactionby/me/cheer rooms
PUT rooms/{0}/interactionby/me/cheer rooms
DELETE rooms/{0}/interactionby/me/favorite rooms
PUT rooms/{0}/interactionby/me/favorite rooms
DELETE rooms/{0}/loadscreen rooms
PUT rooms/{0}/loadscreen rooms
PUT rooms/{0}/max_player_calculation_mode rooms
PUT rooms/{0}/min_level rooms
PUT rooms/{0}/modify rooms
PUT rooms/{0}/name rooms
GET rooms/{0}/playerdata/me rooms
PUT rooms/{0}/playerdata/me rooms
DELETE rooms/{0}/promo_external rooms
PUT rooms/{0}/promo_external rooms
DELETE rooms/{0}/promo_images rooms
PUT rooms/{0}/promo_images rooms
PUT rooms/{0}/restrictions rooms
GET rooms/{0}/roles rooms
GET rooms/{0}/roles/{1} rooms
PUT rooms/{0}/roles/{1} rooms
PUT rooms/{0}/roles/{1}/invite rooms
GET rooms/{0}/similar rooms
POST rooms/{0}/subrooms rooms
DELETE rooms/{0}/subrooms/{1} rooms
PUT rooms/{0}/subrooms/{1}/accessibility rooms
POST rooms/{0}/subrooms/{1}/clone rooms
POST rooms/{0}/subrooms/{1}/data rooms
PUT rooms/{0}/subrooms/{1}/maxplayers rooms
PUT rooms/{0}/subrooms/{1}/modify rooms
POST rooms/{0}/subrooms/{1}/move rooms
PUT rooms/{0}/subrooms/{1}/name rooms
PUT rooms/{0}/subrooms/{1}/permissions rooms
POST rooms/{0}/subrooms/{1}/publish_save rooms
GET rooms/{0}/subrooms/{1}/saves rooms
PUT rooms/{0}/tags rooms
PUT rooms/{0}/voice_chat_encryption rooms
PUT rooms/{0}/warning rooms
GET settings/partyinvite config-telemetry
GET showcase/{0} inventions-media
GET subscription/details/{accountId:int} commerce-subscription
GET subscription/mine/member commerce-subscription
GET subscription/subscriberCount/{accountId:int} commerce-subscription
GET subscription/top/creators/today commerce-subscription
POST subscription/v1/cancel commerce-subscription
POST subscription/{accountId:int} commerce-subscription
GET thread/club/{0} social-clubs-chat
PUT thread/message/{0}/moderate social-clubs-chat
POST thread/withmembers social-clubs-chat
GET thread/{0} social-clubs-chat
POST thread/{0} social-clubs-chat
PUT thread/{0}/favorite social-clubs-chat
POST thread/{0}/leave social-clubs-chat
POST thread/{0}/member/{1} social-clubs-chat
GET thread/{0}/message social-clubs-chat
POST thread/{0}/message/{1}/read social-clubs-chat
POST thread/{0}/rename social-clubs-chat
POST thread/{0}/snooze social-clubs-chat
GET unity_assets/{0}/{1}/{2} inventions-media
GET voice/config moderation-safety
GET voice/requiresModeration moderation-safety
1 # Every HTTP request the Rec Room 2023-03-21 client can issue.
2 # Recovered from the client binary; see docs/recroom-2023-client-api-complete.md.
3 # verb<TAB>route<TAB>subsystem
4 GET account/bulk identity-account
5 POST account/bulk identity-account
6 GET account/isactivecreator/me identity-account
7 DELETE account/me identity-account
8 GET account/me identity-account
9 POST account/me/changepassword identity-account
10 POST account/me/createlogintoken identity-account
11 GET account/me/haspassword identity-account
12 POST account/me/remoteauth identity-account
13 POST account/recoverpassword identity-account
14 GET account/search identity-account
15 GET account/{0}/bio identity-account
16 GET account/{0}/clubs identity-account
17 PUT accountprivacysettings/recenthistoryvisibility identity-account
18 GET accountprivacysettings/{0} identity-account
19 GET accounts/{0}/receives/{1} identity-account
20 POST actionlink config-telemetry
21 GET actionlink/{code} config-telemetry
22 POST actionlink/{code}/consume config-telemetry
23 GET announcements/club/{clubId} config-telemetry
24 POST announcements/club/{clubId} config-telemetry
25 DELETE announcements/club/{clubId}/{announcementId} config-telemetry
26 PUT announcements/club/{clubId}/{announcementId} config-telemetry
27 POST announcements/club/{clubId}/{announcementId}/read config-telemetry
28 GET announcements/mine config-telemetry
29 GET announcements/subscription/mine config-telemetry
30 GET announcements/v2/mine/unread config-telemetry
31 GET announcements/v2/mine/unread?sendAnnouncements=true config-telemetry
32 POST api/AppIntegrity/v1/iospaymentqueuefailed commerce-subscription
33 POST api/AppIntegrity/v1/iosproducts commerce-subscription
34 GET api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers commerce-subscription
35 POST api/CampusCard/v1/UpdateAndGetSubscription commerce-subscription
36 POST api/PlayerCheer/v1/SetSelectedCheer progression-misc
37 POST api/PlayerCheer/v1/create progression-misc
38 POST api/PlayerReporting/v1/deviceId moderation-safety
39 POST api/PlayerReporting/v1/hile moderation-safety
40 POST api/PlayerReporting/v1/instantKick moderation-safety
41 GET api/PlayerReporting/v1/moderationBlockDetails moderation-safety
42 GET api/PlayerReporting/v1/voteToKickReasons moderation-safety
43 POST api/PlayerReporting/v3/create moderation-safety
44 POST api/PlayerReporting/v3/voteToKick moderation-safety
45 GET api/activities/charades/v1/words/{0} progression-misc
46 POST api/ageverification/generateCode moderation-safety
47 GET api/announcement/v1/get config-telemetry
48 GET api/apple/musicpromotion/active commerce-subscription
49 GET api/apple/musicpromotion/code commerce-subscription
50 GET api/avatar/v1/defaultbaseavataritems avatar-inventory
51 GET api/avatar/v1/defaultunlocked avatar-inventory
52 GET api/avatar/v1/lockeditems avatar-inventory
53 GET api/avatar/v2 avatar-inventory
54 GET api/avatar/v2/gifts avatar-inventory
55 POST api/avatar/v2/gifts/generate avatar-inventory
56 POST api/avatar/v2/set avatar-inventory
57 GET api/avatar/v2/{playerId} avatar-inventory
58 POST api/avatar/v3/gifts/generate avatar-inventory
59 GET api/avatar/v3/saved avatar-inventory
60 POST api/avatar/v3/saved/set avatar-inventory
61 GET api/avatar/v4/items avatar-inventory
62 GET api/banappeal/generateCode moderation-safety
63 GET api/catalog/v1/all?onlyAvailableSkus=true avatar-inventory
64 GET api/challenge/v2/getCurrent progression-misc
65 POST api/challenge/v2/updateProgress progression-misc
66 POST api/checklist/v1/complete progression-misc
67 GET api/checklist/v1/current progression-misc
68 POST api/clubreporting/v1/report moderation-safety
69 GET api/communityboard/v2/current progression-misc
70 GET api/config/v1/amplitude config-telemetry
71 GET api/config/v1/azurespeech config-telemetry
72 GET api/config/v1/backtrace config-telemetry
73 GET api/config/v1/cohortnux/{cohortId} config-telemetry
74 GET api/config/v1/freegiftbutton config-telemetry
75 GET api/config/v2 config-telemetry
76 POST api/consumables/v1/consume avatar-inventory
77 GET api/consumables/v1/getTransferable/{playerId} avatar-inventory
78 POST api/consumables/v1/transfer avatar-inventory
79 POST api/consumables/v1/updateActive avatar-inventory
80 GET api/consumables/v2/getUnlocked avatar-inventory
81 POST api/customAvatarItems/v1 avatar-inventory
82 POST api/customAvatarItems/v1/bulk avatar-inventory
83 DELETE api/customAvatarItems/v1/design avatar-inventory
84 GET api/customAvatarItems/v1/design avatar-inventory
85 PUT api/customAvatarItems/v1/design avatar-inventory
86 GET api/customAvatarItems/v1/featured avatar-inventory
87 GET api/customAvatarItems/v1/hot avatar-inventory
88 GET api/customAvatarItems/v1/isCreationAllowedForAccount avatar-inventory
89 GET api/customAvatarItems/v1/isCreationEnabled avatar-inventory
90 GET api/customAvatarItems/v1/isRenderingEnabled avatar-inventory
91 GET api/customAvatarItems/v1/me avatar-inventory
92 GET api/customAvatarItems/v1/minPriceForPublicItem avatar-inventory
93 DELETE api/customAvatarItems/v1/{id} avatar-inventory
94 PUT api/customAvatarItems/v1/{id} avatar-inventory
95 POST api/customAvatarItems/v1/{id}/report avatar-inventory
96 GET api/customAvatarItems/v2/fromCreator/{creatorId} avatar-inventory
97 POST api/equipment/v1/update avatar-inventory
98 GET api/equipment/v2/getUnlocked avatar-inventory
99 POST api/externalfriendinvite/v1/createplatforminvite relationships-messaging
100 POST api/externalfriendinvite/v1/getplatformreferrers relationships-messaging
101 POST api/externalfriendinvite/v1/gettextmessagereferrers relationships-messaging
102 POST api/externalfriendinvite/v1/sendtextmessageinvite relationships-messaging
103 POST api/freegifts/v1/sendmultiple progression-misc
104 GET api/gameconfigs/v1/all config-telemetry
105 GET api/gamerewards/v1/pending progression-misc
106 POST api/gamerewards/v1/request progression-misc
107 POST api/gamerewards/v1/select progression-misc
108 POST api/gamesight/event events-playlists
109 POST api/images/v1/cheer inventions-media
110 POST api/images/v1/deletesaved inventions-media
111 GET api/images/v1/listsaved inventions-media
112 POST api/images/v1/modifyaccessibility inventions-media
113 POST api/images/v1/sendlink inventions-media
114 GET api/images/v1/slideshow inventions-media
115 POST api/images/v1/{id}/report inventions-media
116 GET api/images/v2/named inventions-media
117 GET api/images/v4/room/{roomId}?sort&filter&take&skip inventions-media
118 POST api/images/v4/uploadsaved inventions-media
119 GET api/images/v5/bulk inventions-media
120 GET api/images/v5/cheered/bulk inventions-media
121 GET api/images/v5/player/{playerId}?sort={int} inventions-media
122 GET api/images/v6/{id} inventions-media
123 GET api/images/v6?name={imageName} inventions-media
124 POST api/incentivizedreferrals/claim progression-misc
125 GET api/incentivizedreferrals/progress progression-misc
126 GET api/influencerpartnerprogram/influencer progression-misc
127 GET api/influencerpartnerprogram/influencers progression-misc
128 GET api/influencerpartnerprogram/myinfluencer progression-misc
129 POST api/influencerpartnerprogram/remove progression-misc
130 POST api/influencerpartnerprogram/support progression-misc
131 POST api/inventions/v1/cheer inventions-media
132 GET api/inventions/v1/delete?inventionId={id} inventions-media
133 GET api/inventions/v1/details?inventionId={id} inventions-media
134 GET api/inventions/v1/dormskinsfromids inventions-media
135 GET api/inventions/v1/featured inventions-media
136 GET api/inventions/v1/featureddormskins inventions-media
137 GET api/inventions/v1/fromcreators inventions-media
138 GET api/inventions/v1/fulllineageowner inventions-media
139 GET api/inventions/v1/personaldetails/{id} inventions-media
140 POST api/inventions/v1/report inventions-media
141 GET api/inventions/v1/room?id={roomId} inventions-media
142 POST api/inventions/v1/settags inventions-media
143 GET api/inventions/v1/tagfilters inventions-media
144 GET api/inventions/v1/toptoday inventions-media
145 GET api/inventions/v1/unpublish?inventionId={id} inventions-media
146 GET api/inventions/v1/update inventions-media
147 POST api/inventions/v1/updateprice inventions-media
148 GET api/inventions/v1/version?inventionId={id}&version={n} inventions-media
149 GET api/inventions/v1/versions?inventionId={id} inventions-media
150 GET api/inventions/v1?inventionId={id} inventions-media
151 GET api/inventions/v2/batch inventions-media
152 GET api/inventions/v2/mine inventions-media
153 GET api/inventions/v2/search?value&skip&take inventions-media
154 GET api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price inventions-media
155 POST api/inventions/v4/addversion inventions-media
156 POST api/inventions/v6/save inventions-media
157 POST api/keepsakes events-playlists
158 GET api/keepsakes/categories events-playlists
159 GET api/keepsakes/events/{progressionEventId} events-playlists
160 GET api/keepsakes/globalconfig events-playlists
161 GET api/keepsakes/rooms/{roomId} events-playlists
162 DELETE api/keepsakes/{keepsakeInstanceId:guid} events-playlists
163 POST api/keepsakes/{keepsakeInstanceId:guid}/collect events-playlists
164 GET api/messages/v1/favoriteFriendOnlineStatus relationships-messaging
165 POST api/messages/v1/sendMultiple relationships-messaging
166 GET api/messages/v2/get relationships-messaging
167 POST api/messages/v2/send relationships-messaging
168 POST api/messages/v3/delete relationships-messaging
169 POST api/objectives/v1/cleargroup progression-misc
170 POST api/objectives/v1/completegroup progression-misc
171 GET api/objectives/v1/myprogress progression-misc
172 POST api/objectives/v1/updateobjective progression-misc
173 POST api/offlineinvite/v1/send relationships-messaging
174 GET api/playerevents/v1 events-playlists
175 GET api/playerevents/v1/all events-playlists
176 GET api/playerevents/v1/all/{0} events-playlists
177 POST api/playerevents/v1/broadcast events-playlists
178 POST api/playerevents/v1/bulk events-playlists
179 POST api/playerevents/v1/bulkInvite events-playlists
180 GET api/playerevents/v1/club/{0} events-playlists
181 GET api/playerevents/v1/clubs events-playlists
182 POST api/playerevents/v1/deleteResponse events-playlists
183 POST api/playerevents/v1/report events-playlists
184 POST api/playerevents/v1/respond events-playlists
185 GET api/playerevents/v1/room/{0} events-playlists
186 GET api/playerevents/v1/search events-playlists
187 GET api/playerevents/v1/searchlive events-playlists
188 GET api/playerevents/v1/tagfilters events-playlists
189 GET api/playerevents/v1/{0} events-playlists
190 GET api/playerevents/v1/{0}/responses events-playlists
191 POST api/playerevents/v2 events-playlists
192 POST api/playerevents/v2/delete/{0} events-playlists
193 POST api/playerevents/v2/{0} events-playlists
194 PUT api/playerevents/v2/{0}/accessibility events-playlists
195 PUT api/playerevents/v2/{0}/club events-playlists
196 PUT api/playerevents/v2/{0}/description events-playlists
197 PUT api/playerevents/v2/{0}/image events-playlists
198 PUT api/playerevents/v2/{0}/multiinstance events-playlists
199 PUT api/playerevents/v2/{0}/name events-playlists
200 PUT api/playerevents/v2/{0}/room events-playlists
201 PUT api/playerevents/v2/{0}/tags events-playlists
202 PUT api/playerevents/v2/{0}/time events-playlists
203 PUT api/players/v4/current/contact identity-account
204 POST api/playerwarnings moderation-safety
205 POST api/playerwarnings/acknowledge moderation-safety
206 POST api/playstationplus/expire commerce-subscription
207 GET api/progressionEvents/active events-playlists
208 POST api/progressionEvents/collect/{eventId}/{rewardIndex} events-playlists
209 GET api/progressionEvents/event/{eventId} events-playlists
210 GET api/progressionEvents/record/{progressionEventId} events-playlists
211 GET api/progressionEvents/{eventId}/xpboosts events-playlists
212 GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp events-playlists
213 GET api/quickPlay/v1/getandclear progression-misc
214 GET api/relationships/mutualfriends relationships-messaging
215 POST api/relationships/sendfriendintroductions relationships-messaging
216 GET api/relationships/v1/addfriendwithcode relationships-messaging
217 POST api/relationships/v1/bulkignoreplatformusers relationships-messaging
218 GET api/relationships/v1/favorite relationships-messaging
219 POST api/relationships/v1/ignore moderation-safety
220 POST api/relationships/v1/mute moderation-safety
221 GET api/relationships/v1/unfavorite relationships-messaging
222 POST api/relationships/v1/unignore moderation-safety
223 POST api/relationships/v1/unmute moderation-safety
224 GET api/relationships/v2/acceptfriendrequest relationships-messaging
225 GET api/relationships/v2/addfriend relationships-messaging
226 GET api/relationships/v2/get relationships-messaging
227 GET api/relationships/v2/removefriend relationships-messaging
228 GET api/relationships/v2/sendfriendrequest relationships-messaging
229 POST api/roomCurrencies/v2/purchase room-economy
230 POST api/roomEarningsDistributions/v1/earningsDistribution progression-misc
231 PUT api/roomconsumables/v1/roomConsumable room-economy
232 GET api/roomconsumables/v1/roomConsumable/room/{roomId} room-economy
233 GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me room-economy
234 DELETE api/roomconsumables/v1/roomConsumable/{id} room-economy
235 PUT api/roomconsumables/v1/roomConsumable/{id}/consume room-economy
236 GET api/roomconsumables/v1/roomConsumable/{id}/isOwned room-economy
237 PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency room-economy
238 PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens room-economy
239 POST api/roomcurrencies/v1/awardCurrency/bulk room-economy
240 POST api/roomcurrencies/v1/createCurrency room-economy
241 POST api/roomcurrencies/v1/createPurchaseOffer room-economy
242 GET api/roomcurrencies/v1/currencies room-economy
243 POST api/roomcurrencies/v1/deletePurchaseOffer room-economy
244 GET api/roomcurrencies/v1/getAllBalances room-economy
245 GET api/roomcurrencies/v1/getBalance room-economy
246 GET api/roomcurrencies/v1/getPurchaseOffersBatch room-economy
247 POST api/roomcurrencies/v1/updateCurrency room-economy
248 POST api/roomcurrencies/v1/updatePurchaseOffer room-economy
249 POST api/roomkeys/v1/create room-economy
250 DELETE api/roomkeys/v1/delete/{roomKeyId} room-economy
251 GET api/roomkeys/v1/mine room-economy
252 GET api/roomkeys/v1/owns room-economy
253 POST api/roomkeys/v1/owns/bulk room-economy
254 GET api/roomkeys/v1/purchased/{roomKeyId} room-economy
255 GET api/roomkeys/v1/room?roomId={id} room-economy
256 GET api/rooms/v1/filters rooms
257 POST api/rooms/v1/verifyRole rooms
258 POST api/rooms/v2/report rooms
259 GET api/royale/v1/current progression-misc
260 POST api/royale/v2/matchcomplete progression-misc
261 POST api/sanitize/v1 identity-account
262 POST api/sanitize/v1/isPure identity-account
263 POST api/screensharereports/v1/report moderation-safety
264 POST api/storefronts/v1/PurchaseRoomKeyWithCurrency commerce-subscription
265 GET api/storefronts/v1/adcarouselitems commerce-subscription
266 GET api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int} commerce-subscription
267 POST api/storefronts/v1/buyForFreeGiftButton commerce-subscription
268 POST api/storefronts/v1/buyProgressionEventXpBoost commerce-subscription
269 POST api/storefronts/v1/buyPurchaseReminder commerce-subscription
270 GET api/storefronts/v1/buyRoomKey commerce-subscription
271 POST api/storefronts/v1/objectives commerce-subscription
272 GET api/storefronts/v1/season/{storefrontType:int} commerce-subscription
273 GET api/storefronts/v1/toptoday commerce-subscription
274 POST api/storefronts/v1/trialInvention commerce-subscription
275 GET api/storefronts/v1/trialInvention/duration commerce-subscription
276 POST api/storefronts/v2/balance commerce-subscription
277 POST api/storefronts/v2/buyElite commerce-subscription
278 GET api/storefronts/v2/buyInvention commerce-subscription
279 POST api/storefronts/v2/buyItem commerce-subscription
280 POST api/storefronts/v2/buyTier commerce-subscription
281 GET api/storefronts/v3/giftdropstore/{storefrontType:int} commerce-subscription
282 GET api/storefronts/v4/balance/{currencyType:int} commerce-subscription
283 GET api/subscriptionseasons/v1/seasons/current progression-misc
284 GET api/testcasemanagement/v1/testcase/{id} config-telemetry
285 POST api/testcasemanagement/v1/testcase/{id}/claim config-telemetry
286 POST api/testcasemanagement/v1/testcase/{id}/comment config-telemetry
287 POST api/testcasemanagement/v1/testcase/{id}/status config-telemetry
288 POST api/testcasemanagement/v1/testcase/{id}/unclaim config-telemetry
289 GET api/testcasemanagement/v1/testpass/{id} config-telemetry
290 GET api/testcasemanagement/v1/testpasssummary config-telemetry
291 POST api/ugcPurchasables/v1/items/bulk avatar-inventory
292 GET api/ugcPurchasables/v1/items/room/{roomId} avatar-inventory
293 GET api/versioncheck/v4 identity-account
294 DELETE cachedlogin/current identity-account
295 GET cachedlogin/forplatformid/{0}/{1} identity-account
296 POST cachedlogin/forplatformids identity-account
297 POST cachedlogin/migrate identity-account
298 GET club/account/{0}/created social-clubs-chat
299 GET club/categoryTags social-clubs-chat
300 POST club/create social-clubs-chat
301 DELETE club/home/me social-clubs-chat
302 GET club/home/me social-clubs-chat
303 PUT club/home/me social-clubs-chat
304 GET club/mine/created social-clubs-chat
305 GET club/mine/member social-clubs-chat
306 GET club/mostactivetoday social-clubs-chat
307 GET club/search social-clubs-chat
308 DELETE club/{0} social-clubs-chat
309 GET club/{0} social-clubs-chat
310 DELETE club/{0}/additionalimage/{1} social-clubs-chat
311 PUT club/{0}/additionalimage/{1} social-clubs-chat
312 PUT club/{0}/clubChatEnabled social-clubs-chat
313 DELETE club/{0}/clubhouse social-clubs-chat
314 PUT club/{0}/clubhouse social-clubs-chat
315 GET club/{0}/details social-clubs-chat
316 GET club/{0}/hasDisabledClubChat social-clubs-chat
317 DELETE club/{0}/mainimage social-clubs-chat
318 PUT club/{0}/mainimage social-clubs-chat
319 GET club/{0}/members social-clubs-chat
320 POST club/{0}/members/acceptinvite social-clubs-chat
321 POST club/{0}/members/acceptrequest social-clubs-chat
322 POST club/{0}/members/acceptrequests social-clubs-chat
323 POST club/{0}/members/ban social-clubs-chat
324 GET club/{0}/members/banned social-clubs-chat
325 PUT club/{0}/members/changetype social-clubs-chat
326 POST club/{0}/members/declineinvite social-clubs-chat
327 POST club/{0}/members/denyrequest social-clubs-chat
328 POST club/{0}/members/denyrequests social-clubs-chat
329 POST club/{0}/members/directJoin social-clubs-chat
330 PUT club/{0}/members/invite social-clubs-chat
331 POST club/{0}/members/invitemembers social-clubs-chat
332 POST club/{0}/members/leave social-clubs-chat
333 POST club/{0}/members/remove social-clubs-chat
334 GET club/{0}/members/requests social-clubs-chat
335 GET club/{0}/members/requests/search social-clubs-chat
336 PUT club/{0}/members/requesttojoin social-clubs-chat
337 GET club/{0}/members/search social-clubs-chat
338 POST club/{0}/members/unban social-clubs-chat
339 GET club/{0}/members/{1} social-clubs-chat
340 PUT club/{0}/minlevel social-clubs-chat
341 PUT club/{0}/modify social-clubs-chat
342 PUT club/{0}/modifydetails social-clubs-chat
343 PUT club/{0}/permissions/{1} social-clubs-chat
344 GET clubhousesearch/mostactivenow social-clubs-chat
345 POST comments/create/{0} social-clubs-chat
346 DELETE comments/delete/{0} social-clubs-chat
347 GET comments/get/{0} social-clubs-chat
348 PUT comments/read/{0}/{1} social-clubs-chat
349 GET comments/unreadcounts social-clubs-chat
350 GET config/categories config-telemetry
351 POST connect/deviceauthorization identity-account
352 POST connect/token identity-account
353 POST data/event events-playlists
354 PUT datalink config-telemetry
355 GET datalink/{code} config-telemetry
356 GET eac/challenge identity-account
357 GET econ/customAvatarItems/v1/itemOwnershipLimit avatar-inventory
358 GET econ/customAvatarItems/v1/owned avatar-inventory
359 POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} avatar-inventory
360 GET emojiConfig/whitelistedEmojis config-telemetry
361 GET event/{0}/instances events-playlists
362 GET featuredrooms/current rooms
363 POST feed/query config-telemetry
364 DELETE invite/{0} progression-misc
365 POST leaderboard/CheckAndSetStat progression-misc
366 POST leaderboard/GetNearbyScores progression-misc
367 POST leaderboard/GetPlayerRank progression-misc
368 POST leaderboard/GetRanks progression-misc
369 POST matchmake/chatinvite/{0}/{1} matchmaking-session
370 POST matchmake/club/{0} matchmaking-session
371 POST matchmake/code/{0}/{1} matchmaking-session
372 POST matchmake/dorm matchmaking-session
373 POST matchmake/event/{0} matchmaking-session
374 POST matchmake/instance/{0} matchmaking-session
375 POST matchmake/invite/{0} matchmaking-session
376 POST matchmake/none matchmaking-session
377 POST matchmake/player/{0} matchmaking-session
378 POST matchmake/room/{0} matchmaking-session
379 POST matchmake/room/{0}/{1} matchmaking-session
380 GET members/bulk social-clubs-chat
381 GET namegen/options identity-account
382 POST pageview/consume config-telemetry
383 GET parentalcontrol/me identity-account
384 GET platformid/{0} identity-account
385 GET player/avoidjuniors matchmaking-session
386 POST player/exclusivelogin matchmaking-session
387 POST player/heartbeat matchmaking-session
388 POST player/login matchmaking-session
389 POST player/logout matchmaking-session
390 POST player/notifydisconnect matchmaking-session
391 PUT player/photonregionpings matchmaking-session
392 PUT player/statusvisibility matchmaking-session
393 PUT player/vrmovementmode matchmaking-session
394 POST playlists/bulk events-playlists
395 GET playlists/cheeredby/me events-playlists
396 GET playlists/createdby/me events-playlists
397 GET playlists/favoritedby/me events-playlists
398 GET playlists/visitedby/me events-playlists
399 DELETE playlists/{0} events-playlists
400 GET playlists/{0} events-playlists
401 PUT playlists/{0}/accessibility events-playlists
402 PUT playlists/{0}/description events-playlists
403 PUT playlists/{0}/image events-playlists
404 GET playlists/{0}/interactionby/me events-playlists
405 PUT playlists/{0}/interactionby/me/cheer events-playlists
406 PUT playlists/{0}/interactionby/me/favorite events-playlists
407 PUT playlists/{0}/levelvoting events-playlists
408 PUT playlists/{0}/name events-playlists
409 PUT playlists/{0}/restrictions events-playlists
410 PUT playlists/{0}/rooms/{1} events-playlists
411 PUT playlists/{0}/tags events-playlists
412 PUT playlists/{0}/warning events-playlists
413 POST purchase/v1/cancelpurchase commerce-subscription
414 POST purchase/v1/cleanuppending commerce-subscription
415 POST purchase/v1/completepurchase commerce-subscription
416 GET purchase/v1/hasspentmoney commerce-subscription
417 POST purchase/v1/initiatepurchase commerce-subscription
418 POST purchase/v1/processpurchase commerce-subscription
419 GET purchasecampaign/allcurrent/v2 commerce-subscription
420 POST purchasecampaign/shown commerce-subscription
421 GET reminder/currentTokenBundles/v2 commerce-subscription
422 POST remote-run/push-to-studio inventions-media
423 GET role/developer/{0} progression-misc
424 GET role/moderator/{0} progression-misc
425 GET room/{0}/instances rooms
426 PUT roominstance/{0}/inprogress rooms
427 PUT roominstance/{0}/markprivate rooms
428 POST roominstance/{0}/reportjoinresult rooms
429 PUT roominstance/{0}/roomCode rooms
430 GET rooms/base rooms
431 GET rooms/bulk rooms
432 GET rooms/cheeredby/me rooms
433 GET rooms/contestwinners rooms
434 GET rooms/createdby/me rooms
435 GET rooms/createdby/{0} rooms
436 GET rooms/curated_playlists rooms
437 GET rooms/favoritedby/me rooms
438 GET rooms/fromcreators rooms
439 GET rooms/hot rooms
440 GET rooms/magic_door rooms
441 GET rooms/moderatedby/me rooms
442 GET rooms/ownedby/me rooms
443 GET rooms/ownedby/{0} rooms
444 GET rooms/recommendations rooms
445 GET rooms/rro_ids rooms
446 GET rooms/search rooms
447 GET rooms/topcreators rooms
448 GET rooms/visitedby/me rooms
449 GET rooms/visitedby/{0} rooms
450 DELETE rooms/{0} rooms
451 GET rooms/{0} rooms
452 PUT rooms/{0}/accessibility rooms
453 PUT rooms/{0}/allow_new_users rooms
454 PUT rooms/{0}/automute rooms
455 GET rooms/{0}/bans rooms
456 POST rooms/{0}/bans rooms
457 POST rooms/{0}/bans/import rooms
458 DELETE rooms/{0}/bans/{1} rooms
459 POST rooms/{0}/clone rooms
460 PUT rooms/{0}/cloning rooms
461 PUT rooms/{0}/comments rooms
462 PUT rooms/{0}/creator rooms
463 PUT rooms/{0}/description rooms
464 PUT rooms/{0}/image rooms
465 GET rooms/{0}/interactionby/me rooms
466 DELETE rooms/{0}/interactionby/me/cheer rooms
467 PUT rooms/{0}/interactionby/me/cheer rooms
468 DELETE rooms/{0}/interactionby/me/favorite rooms
469 PUT rooms/{0}/interactionby/me/favorite rooms
470 DELETE rooms/{0}/loadscreen rooms
471 PUT rooms/{0}/loadscreen rooms
472 PUT rooms/{0}/max_player_calculation_mode rooms
473 PUT rooms/{0}/min_level rooms
474 PUT rooms/{0}/modify rooms
475 PUT rooms/{0}/name rooms
476 GET rooms/{0}/playerdata/me rooms
477 PUT rooms/{0}/playerdata/me rooms
478 DELETE rooms/{0}/promo_external rooms
479 PUT rooms/{0}/promo_external rooms
480 DELETE rooms/{0}/promo_images rooms
481 PUT rooms/{0}/promo_images rooms
482 PUT rooms/{0}/restrictions rooms
483 GET rooms/{0}/roles rooms
484 GET rooms/{0}/roles/{1} rooms
485 PUT rooms/{0}/roles/{1} rooms
486 PUT rooms/{0}/roles/{1}/invite rooms
487 GET rooms/{0}/similar rooms
488 POST rooms/{0}/subrooms rooms
489 DELETE rooms/{0}/subrooms/{1} rooms
490 PUT rooms/{0}/subrooms/{1}/accessibility rooms
491 POST rooms/{0}/subrooms/{1}/clone rooms
492 POST rooms/{0}/subrooms/{1}/data rooms
493 PUT rooms/{0}/subrooms/{1}/maxplayers rooms
494 PUT rooms/{0}/subrooms/{1}/modify rooms
495 POST rooms/{0}/subrooms/{1}/move rooms
496 PUT rooms/{0}/subrooms/{1}/name rooms
497 PUT rooms/{0}/subrooms/{1}/permissions rooms
498 POST rooms/{0}/subrooms/{1}/publish_save rooms
499 GET rooms/{0}/subrooms/{1}/saves rooms
500 PUT rooms/{0}/tags rooms
501 PUT rooms/{0}/voice_chat_encryption rooms
502 PUT rooms/{0}/warning rooms
503 GET settings/partyinvite config-telemetry
504 GET showcase/{0} inventions-media
505 GET subscription/details/{accountId:int} commerce-subscription
506 GET subscription/mine/member commerce-subscription
507 GET subscription/subscriberCount/{accountId:int} commerce-subscription
508 GET subscription/top/creators/today commerce-subscription
509 POST subscription/v1/cancel commerce-subscription
510 POST subscription/{accountId:int} commerce-subscription
511 GET thread/club/{0} social-clubs-chat
512 PUT thread/message/{0}/moderate social-clubs-chat
513 POST thread/withmembers social-clubs-chat
514 GET thread/{0} social-clubs-chat
515 POST thread/{0} social-clubs-chat
516 PUT thread/{0}/favorite social-clubs-chat
517 POST thread/{0}/leave social-clubs-chat
518 POST thread/{0}/member/{1} social-clubs-chat
519 GET thread/{0}/message social-clubs-chat
520 POST thread/{0}/message/{1}/read social-clubs-chat
521 POST thread/{0}/rename social-clubs-chat
522 POST thread/{0}/snooze social-clubs-chat
523 GET unity_assets/{0}/{1}/{2} inventions-media
524 GET voice/config moderation-safety
525 GET voice/requiresModeration moderation-safety
@@ -21,6 +21,14 @@ public class CustomAvatarItemsController(
IObjectStorage storage,
ILogger<CustomAvatarItemsController> logger) : ControllerBase
{
/// <summary>Floor price for a publicly listed custom item. The client
/// asks for this over the wire (<c>api/customAvatarItems/v1/minPriceForPublicItem</c>,
/// RecNet.Runtime/MPBLNLMCEDL.txt:6893) and then enforces it locally in the
/// designer, so the number the create/update paths clamp to and the number
/// served on that route have to be the same constant or the client's own
/// validation disagrees with the server's rejection.</summary>
private const int MinPublicItemPrice = 100;
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> List(
@@ -529,7 +537,7 @@ public class CustomAvatarItemsController(
item.Name = name;
item.Description = description;
item.Price = Math.Max(100, req.Price ?? item.Price);
item.Price = Math.Max(MinPublicItemPrice, req.Price ?? item.Price);
item.ItemType = req.ItemType ?? item.ItemType;
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
item.Color = Trim(req.Color, 64);
@@ -939,6 +947,30 @@ public class CustomAvatarItemsController(
public List<Guid>? ItemIds { get; set; }
}
/// <summary>The designer's in-progress draft, as persisted to the
/// per-player <c>caidesign_p&lt;id&gt;.json</c> object (see
/// <see cref="LoadDesignAsync"/>). Mirrors the three payload properties of
/// the client's design DTOs — the metadata object GIOBJIGEMNG carries the
/// nullable base-item id and the "#RRGGBB" colour, and the texture arrives
/// as the separate "design" file part whose stored blob name lands in
/// <see cref="ImageName"/>.
///
/// <see cref="Deleted"/> is the tombstone: <see cref="IObjectStorage"/>
/// exposes no delete operation, so DELETE design overwrites the record with
/// a deleted marker and LoadDesignAsync reports it as "no saved design".
/// Public properties with setters because the round-trip goes through
/// System.Text.Json.</summary>
public sealed class StoredDesign
{
public int? BaseAvatarItemId { get; set; }
public string Color { get; set; } = string.Empty;
public string ImageName { get; set; } = string.Empty;
public bool Deleted { get; set; }
}
public sealed class DesignRequest
{
public Guid? Id { get; set; }
@@ -387,19 +387,59 @@ public class InventionsController(
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize&lt;T&gt;</c>
/// and surfaced as "Failed to get tags: Malformed Response"
/// (output_log.txt:1602+1639) — and that bubbles up as the
/// dorm-load destabilisation we've been hunting.</summary>
/// dorm-load destabilisation we've been hunting.
///
/// The 2023 DTO reads a THIRD list, <c>TrendingFilters</c>
/// (HMDONHHKKGA.txt:099/169), which we never sent — callers projecting that
/// list got an empty filter row. Popular/Trending are now derived from the
/// tags actually in use (<see cref="InventionEntity.TagsCsv"/>) rather than
/// echoing the pinned array three times; the curated list is the fallback
/// so a fresh server still shows filters. The 2020.12 watch's
/// <c>AEBEPCMAABC</c> reads only Pinned/Popular, so the extra key is
/// inert there.</summary>
[HttpGet("api/inventions/v1/tagfilters")]
public IActionResult TagFilters()
public async Task<IActionResult> TagFilters()
{
var tags = new[]
{
string[] pinned =
[
"sport", "game", "vehicle", "weapon", "decor", "tool",
"art", "music", "puzzle", "combat", "build", "race",
};
];
// TagsCsv is a packed list no provider can split, so the frequency count
// runs in memory over a bounded window of the most recently touched
// tagged inventions.
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "")
.OrderByDescending(i => i.UpdatedAt)
.Select(i => new { i.TagsCsv, i.UpdatedAt })
.Take(2000)
.ToListAsync();
static IEnumerable<string> SplitTags(string csv) => csv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
static List<string> Rank(IEnumerable<string> tags) => tags
.GroupBy(t => t, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => g.Key)
.Take(12)
.ToList();
var since = DateTime.UtcNow.AddDays(-7);
var popular = Rank(rows.SelectMany(r => SplitTags(r.TagsCsv)));
var trending = Rank(rows.Where(r => r.UpdatedAt >= since)
.SelectMany(r => SplitTags(r.TagsCsv)));
if (popular.Count == 0) popular = [.. pinned];
if (trending.Count == 0) trending = popular;
return Ok(new
{
PinnedFilters = tags,
PopularFilters = tags,
PinnedFilters = pinned,
PopularFilters = popular,
TrendingFilters = trending,
});
}
@@ -520,18 +560,26 @@ public class InventionsController(
return Ok(ToWire(i));
}
/// <summary>POST <c>api/storefronts/v1/trialInvention?inventionId={id}</c>
/// — start a free trial of a store invention. Client
/// (RecNet.Runtime <c>DCFKEFHJAGC.PPCLLFMHJLD(long)</c>) expects a SINGLE
/// Invention DTO back (same <see cref="ToWire"/> shape it uses for
/// search), which it spawns for the trial window (length from
/// <c>trialInvention/duration</c>). Hosted here — not in
/// <summary>POST <c>api/storefronts/v1/trialInvention</c> — start a free
/// trial of a store invention, spawned for the window returned by
/// <c>trialInvention/duration</c>. Hosted here — not in
/// StorefrontsController — so it can reuse the invention wire builder.
/// We record the trial start so it's an auditable action.</summary>
/// We record the trial start so it's an auditable action.
///
/// TWO wire corrections. (1) The id is a FORM field, not a query param:
/// DCFKEFHJAGC.txt:9037 sets verb 2 (POST) and :9050 adds the pair via
/// <c>AFGEDDANEKP("inventionId", …)</c>, and on a non-GET those pairs go
/// into an HTTPUrlEncodedForm body (see the id-list block comment above), so
/// <c>[FromQuery]</c> bound 0 and every trial 404'd. (2) The reply is the
/// Status/Invention/InventionVersion envelope: the continuation at
/// DCFKEFHJAGC.txt:9086 is a
/// <c>Func&lt;BDNCJIPHHOK, FGLDKEJLAKB&lt;IFJONDCAKKM&gt;&gt;</c> which
/// projects <c>.Invention</c> — a bare invention left that null.</summary>
[HttpPost("api/storefronts/v1/trialInvention")]
[Authorize]
public async Task<ActionResult> TrialInvention([FromQuery] long inventionId)
public async Task<ActionResult> TrialInvention()
{
var inventionId = await ReadLongFieldAsync("inventionId") ?? 0;
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
@@ -550,9 +598,21 @@ public class InventionsController(
row.Value = DateTime.UtcNow.ToString("O");
await db.SaveChangesAsync();
}
return Ok(ToWire(i));
return Ok(await EnvelopeAsync(i));
}
/// <summary>GET <c>api/inventions/v1/details</c> — the ONLY key either
/// client reads off this response is <c>Tags</c>: the 2023 continuation is
/// <c>Action&lt;OIABGAKJABE&gt;</c> (OEGFNFEAAGO.txt:6312) whose reader takes
/// exactly one literal, "Tags" (DBAHPLOPFIO.txt:034), and the 2020.12 watch's
/// HJPDBNLCGIB does the same. Each element is
/// <c>{Tag:String, Type:Int32}</c> (PCMLHLIBLNJ.txt:038/057). Without the key
/// the invention detail page's tag row was permanently empty. Invention +
/// Versions stay on the wire — unknown members are ignored by both readers.
/// <c>Type</c> is always 0: <see cref="SetTags"/> merges the client's
/// AutoTags and CustomTags into one <see cref="InventionEntity.TagsCsv"/>
/// column, so the auto/custom distinction is not recoverable without a
/// second column.</summary>
[HttpGet("api/inventions/v1/details")]
public async Task<ActionResult> Details([FromQuery] long inventionId)
{
@@ -566,21 +626,33 @@ public class InventionsController(
.ToListAsync();
return Ok(new
{
Tags = i.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => new { Tag = t, Type = 0 }),
Invention = ToWire(i),
Versions = versions.Select(ToVersionWire),
});
}
/// <summary>GET <c>api/inventions/v1/personaldetails/{id}</c> — the caller's
/// own relationship to the invention. Both clients read a single key here:
/// the 2023 continuation is <c>FGLDKEJLAKB&lt;CEAFHBOOBKL&gt;</c>
/// (OEGFNFEAAGO.txt:8995) and CEAFHBOOBKL's reader takes only "IsCheering"
/// (BCLCHNGENDE.txt:036); the 2020.12 watch's OEGPIPBKHCN is identical.
/// Without it the detail page never lit the player's own cheer.</summary>
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
[Authorize]
public async Task<ActionResult> PersonalDetails(long id)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
if (i is null) return NotFound();
var pid = CurrentPlayerId;
return Ok(new
{
IsCheering = await db.Cheers.AnyAsync(c =>
c.FromPlayerId == pid && c.TargetInventionId == id),
Invention = ToWire(i),
CanEdit = i.CreatorPlayerId == CurrentPlayerId,
CanEdit = i.CreatorPlayerId == pid,
});
}
@@ -613,6 +685,14 @@ public class InventionsController(
public string? ImageName { get; set; }
public int InstantiationCost { get; set; }
public int LightsCost { get; set; }
/// <summary>RecNet.NewInventionRequestDTO carries <c>chipsCost</c> and
/// <c>cloudVariablesCost</c> too (2023.06 dump.cs:1283258-1283259).
/// <see cref="InventionVersionEntity"/> has no column for either, so
/// they cannot be persisted — but binding them lets the save response
/// echo back the budget the client just reported instead of two
/// zeroes.</summary>
public int ChipsCost { get; set; }
public int CloudVariablesCost { get; set; }
public int AiCost { get; set; }
public long? CreationRoomId { get; set; }
public string? InventionDataFilename { get; set; }
@@ -686,7 +766,8 @@ public class InventionsController(
{
Status = 0,
Invention = ToWireV4(inv),
InventionVersion = ToVersionWire(v1),
InventionVersion = ToVersionWire(
v1, Math.Max(0, req.ChipsCost), Math.Max(0, req.CloudVariablesCost)),
});
}
@@ -727,7 +808,7 @@ public class InventionsController(
inv.CurrentVersionNumber = nextVer;
inv.UpdatedAt = DateTime.UtcNow;
db.InventionVersions.Add(new InventionVersionEntity
var version = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
@@ -735,9 +816,20 @@ public class InventionsController(
BlobName = blobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
});
};
db.InventionVersions.Add(version);
await db.SaveChangesAsync();
return Ok(ToWire(inv));
// Envelope, not a bare invention — see EnvelopeAsync. The version we
// just wrote is the one the client wants back, and the two cost fields
// it sent are echoed because no column stores them.
return Ok(new
{
Status = 0,
Invention = ToWire(inv),
InventionVersion = ToVersionWire(
version, Math.Max(0, req.ChipsCost ?? 0), Math.Max(0, req.CloudVariablesCost ?? 0)),
});
}
// ── GET-based mutations (Core.Get pattern) ───────────────────────────
@@ -775,14 +867,42 @@ public class InventionsController(
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
/// <summary>Body shape for the POST form of <c>updateprice</c> —
/// RecNet.UpdatePriceRequest, real field names preserved in the 2023.06
/// dump (dump.cs:1283355-1283356). Settable properties + a parameterless
/// ctor because <see cref="Binding.FormOrJsonModelBinder"/> constructs the
/// instance itself.</summary>
public sealed class UpdatePriceRequest
{
public long InventionId { get; set; }
public int Price { get; set; }
}
/// <summary>Both clients POST <c>updateprice</c> with a body; only the GET
/// query form was registered, so ASP.NET answered 405 and re-pricing a paid
/// invention always failed. 2023: OEGFNFEAAGO.txt:9850 sets verb 2 and
/// :9870-9876 hands <c>JsonUtility.ToJson(UpdatePriceRequest)</c> to
/// <c>FJLLPHFOOJJ</c>, which wraps it in a BestHTTP RawJsonForm
/// (<c>application/json</c>, RawJsonForm.txt:40). December POSTs the same
/// route form-encoded (2020.12 BBHENFCNLAB.txt:6480). The binder accepts
/// both; the GET stays for callers that still use the query string.</summary>
[HttpGet("api/inventions/v1/updateprice")]
[Authorize]
public async Task<ActionResult> UpdatePrice(
public Task<ActionResult> UpdatePrice(
[FromQuery] long inventionId,
[FromQuery] int price)
=> ApplyPriceAsync(inventionId, price);
[HttpPost("api/inventions/v1/updateprice")]
[Authorize]
public Task<ActionResult> UpdatePricePost(
[ModelBinder(typeof(Binding.FormOrJsonModelBinder))] UpdatePriceRequest req)
=> ApplyPriceAsync(req.InventionId, req.Price);
private async Task<ActionResult> ApplyPriceAsync(long inventionId, int price)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
@@ -791,7 +911,7 @@ public class InventionsController(
inv.Price = Math.Clamp(price, 0, 1000000);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/delete")]
@@ -807,13 +927,29 @@ public class InventionsController(
inv.IsPublished = false;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
/// <summary>The 2023 publish call carries FOUR query params, not two:
/// OEGFNFEAAGO.txt:9437/9449/9461/9473 add <c>inventionId</c>,
/// <c>permissionLevel</c>, <c>accessibility</c> and a
/// <c>Nullable&lt;Int32&gt;</c> <c>price</c> — the wrapper signature is
/// OEGFNFEAAGO.txt:9216. Dropping accessibility meant "publish as Private /
/// Unlisted" still went fully public, and a paid publish lost its price.
/// Accessibility constants: Private=0, Public=1, Unlisted=2
/// (AEFFFPIJDHG.GAKJKOGJEEH, 2023.06 dump.cs:1282644-1282646). With no
/// Accessibility column on the entity only the Private/not-Private half can
/// be stored, so Unlisted currently behaves as Public. The param is
/// NULLABLE on purpose — the 2020.12 watch sends only inventionId +
/// permissionLevel (2020.12 BBHENFCNLAB.txt:6171), and a missing value must
/// not be read as Private or December could never publish.</summary>
[HttpGet("api/inventions/v3/publish")]
[Authorize]
public async Task<ActionResult> Publish(
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
[FromQuery] long inventionId,
[FromQuery] int permissionLevel,
[FromQuery] int? accessibility,
[FromQuery] int? price)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
@@ -821,13 +957,15 @@ public class InventionsController(
if (inv.CreatorPlayerId != pid) return Forbid();
var perm = ClampInventionPermission(permissionLevel);
if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)");
var access = accessibility ?? 1 /* Public */;
inv.GeneralPermission = perm;
inv.IsPublished = true;
inv.FirstPublishedAt ??= DateTime.UtcNow;
inv.IsPublished = access != 0;
if (inv.IsPublished) inv.FirstPublishedAt ??= DateTime.UtcNow;
inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0);
if (price.HasValue) inv.Price = Math.Clamp(price.Value, 0, 1000000);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/unpublish")]
@@ -843,7 +981,7 @@ public class InventionsController(
inv.Permission = 0;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/download")]
@@ -870,13 +1008,30 @@ public class InventionsController(
/// <summary>The client sends <c>AutoTags</c> and <c>CustomTags</c> as JSON
/// ARRAYS of strings. Binding them to <c>string</c> made deserialization
/// throw, so every tag edit 400'd. <c>PlayerAddedTags</c> is kept as an
/// alias for older callers.</summary>
/// alias for older callers. Field names are literal in the 2023.06 dump —
/// RecNet.ModifyTagsRequest keeps real names (dump.cs:1283295-1283301) —
/// and the body is a RAW JSON document
/// (<c>JsonUtility.ToJson</c> → <c>BNDIAONDFFF.FJLLPHFOOJJ</c>,
/// OEGFNFEAAGO.txt:3082-3088), so [FromBody] is right here.</summary>
public sealed record SetTagsRequest(
long InventionId,
List<string>? AutoTags,
List<string>? CustomTags,
List<string>? PlayerAddedTags);
/// <summary>POST <c>api/inventions/v1/settags</c> — the reply is NOT an
/// invention. The 2023 issuing method is typed
/// <c>FGLDKEJLAKB&lt;PNGLFHEAJIH&gt;</c> (OEGFNFEAAGO.txt:2744) and
/// PNGLFHEAJIH exposes exactly two members — an int-backed result enum and
/// <c>List&lt;String&gt;</c> (PNGLFHEAJIH.txt:3/83) — read from the literals
/// "Result" and "Tags" (ALJPHDEAHBK.txt:191/210). The 2020.12 watch is
/// identical: NJMAEIPIOAP.PPGFHEDFBEA pulls "Result" then "Tags"
/// (2020.12 NJMAEIPIOAP.txt:85/90) with the throwing Util.GetKey, so the
/// old bare-invention reply killed every tag edit on December too.
/// <c>Result = 0</c> is success: the enum's message formatter jump-tables on
/// the value and case 0 returns "Success!" (PNGLFHEAJIH.txt:334-341).
/// <c>Tags</c> is a flat string list — NOT the {Tag,Type} pairs
/// <see cref="Details"/> returns.</summary>
[HttpPost("api/inventions/v1/settags")]
[Authorize]
public async Task<ActionResult> SetTags([FromBody] SetTagsRequest req)
@@ -885,20 +1040,39 @@ public class InventionsController(
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var combined = string.Join(',', new[] { req.AutoTags, req.CustomTags, req.PlayerAddedTags }
var tags = new[] { req.AutoTags, req.CustomTags, req.PlayerAddedTags }
.Where(list => list is not null)
.SelectMany(list => list!)
.Select(t => t?.Trim())
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct(StringComparer.OrdinalIgnoreCase));
inv.TagsCsv = combined;
.Select(t => (t ?? string.Empty).Trim())
.Where(t => t.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
inv.TagsCsv = string.Join(',', tags);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(new { Result = 0, Tags = tags });
}
public sealed record CheerRequest(long InventionId);
/// <summary>RecNet.CheerRequest carries a SECOND field — <c>Cheer</c>, the
/// desired state — whose real name survives in the 2023.06 dump
/// (dump.cs:1283338-1283342: <c>long InventionId</c> @0x10,
/// <c>bool Cheer</c> @0x18). Both writes are visible in the ISIL: 2023 sets
/// [rdi+16] and [rdi+24] before <c>JsonUtility.ToJson</c>
/// (OEGFNFEAAGO.txt:11228-11233) and December does the same
/// (2020.12 BBHENFCNLAB.txt:7624-7628) — its wrapper signature is
/// <c>ANJHOOPIAKM(Int64, Boolean)</c> (BBHENFCNLAB.txt:7503). Ignoring the
/// flag made un-cheering a no-op: the button toggled off in the UI and the
/// cheer stayed on the invention forever.</summary>
public sealed record CheerRequest(long InventionId, bool Cheer = true);
/// <summary>POST <c>api/inventions/v1/cheer</c> — answers with the
/// Status/Invention/InventionVersion envelope like every other invention
/// mutation: the 2023 method is <c>FGLDKEJLAKB&lt;BDNCJIPHHOK&gt;</c>
/// (OEGFNFEAAGO.txt:11065) and December's is
/// <c>IPromise&lt;AHEPPAEOLOD&gt;</c> (2020.12 BBHENFCNLAB.txt:7503). The old
/// <c>{Id, CheerCount}</c> reply left Invention null on 2023 and threw
/// KeyNotFoundException on December, so the tile never refreshed its
/// count.</summary>
[HttpPost("api/inventions/v1/cheer")]
[Authorize]
public async Task<ActionResult> Cheer([FromBody] CheerRequest req)
@@ -907,10 +1081,11 @@ public class InventionsController(
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
// Idempotent: one cheer per (player, invention).
// Idempotent in both directions: one cheer row per (player, invention).
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == pid && c.TargetInventionId == req.InventionId);
if (existing is null)
if (req.Cheer && existing is null)
{
db.Cheers.Add(new CheerEntity
{
@@ -926,12 +1101,29 @@ public class InventionsController(
PushNotificationId.InventionModerationStateChanged,
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
}
return Ok(new { inv.Id, inv.CheerCount });
else if (!req.Cheer && existing is not null)
{
db.Cheers.Remove(existing);
inv.CheerCount = Math.Max(0, inv.CheerCount - 1);
await db.SaveChangesAsync();
}
return Ok(await EnvelopeAsync(inv));
}
public sealed record ReportInventionRequest(
long InventionId, int? ReportCategory, string? Details);
/// <summary>POST <c>api/inventions/v1/report</c> — the reply is a
/// success/message pair, not a status flag of our own invention. The 2023
/// method is <c>FGLDKEJLAKB&lt;PHMHCPEMABG&gt;</c> (OEGFNFEAAGO.txt:10917)
/// and PHMHCPEMABG holds a Boolean + a String (PHMHCPEMABG.txt:3/23) read
/// from the literals "Success"/"Message" (GBPDOLJBABB.txt:191/210); the
/// 2020.12 watch's KLAMKCBENEA.PPGFHEDFBEA pulls the same two literals
/// (2020.12 KLAMKCBENEA.txt:85/90). Our old <c>{Reported:true}</c> left
/// Success at default false, so the report UI reported failure even though
/// the row persisted. <c>Message</c> must be a string — December's
/// Util.GetKey casts it — so send "" rather than null.</summary>
[HttpPost("api/inventions/v1/report")]
[Authorize]
public async Task<ActionResult> Report([FromBody] ReportInventionRequest req)
@@ -949,7 +1141,7 @@ public class InventionsController(
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
return Ok(new { Success = true, Message = string.Empty });
}
// ── Wire serializers ─────────────────────────────────────────────────
@@ -1000,13 +1192,40 @@ public class InventionsController(
i.CreatorPermission,
i.GeneralPermission,
IsAGInvention = i.IsAgInvention,
// The 2023-03-21 DTO dropped IsPublished and reads "Accessibility"
// instead (IOEPPCKGBFL.txt:309 / :1602 — the member switch has no
// IsPublished arm at all). Constants come from the nested enum
// AEFFFPIJDHG.GAKJKOGJEEH in the 2023.06 dump
// (dump.cs:1282644-1282646): Private=0, Public=1, Unlisted=2. With the
// key absent the reader left the field at its default, so EVERY
// published store invention presented as Private in the 2023 UI.
// InventionEntity has no Accessibility column, so only the
// Private/Public bit round-trips; Unlisted needs a real column.
// IsPublished stays on the wire because the 2020.12 watch's
// OBBBPCBIMME reader still requires it.
Accessibility = i.IsPublished ? 1 : 0,
// IOEPPCKGBFL.txt:565 — DorkNet runs no certification programme.
IsCertifiedInvention = false,
i.Price,
HideFromPlayer = false,
};
private static object ToWireV4(InventionEntity i) => ToWire(i);
private static object ToVersionWire(InventionVersionEntity v) => new
private static object ToVersionWire(InventionVersionEntity v)
=> ToVersionWire(v, 0, 0);
/// <summary>The 2023 InventionVersion DTO reads NINE keys
/// (OLPHKLCPFEF.txt:084-295): the six we already emitted plus
/// <c>ChipsCost</c>, <c>CloudVariablesCost</c> and <c>BlobHash</c>.
/// <see cref="InventionVersionEntity"/> has no column for any of the three,
/// so stored rows report 0/empty and only the save/addversion round-trip can
/// echo back the costs the client just sent. An empty BlobHash is safe:
/// PLIKEBBPJGI's hash getter (<c>EHENFDMIAIM</c>, OLPHKLCPFEF's owner type
/// PLIKEBBPJGI.txt:167) has no call site anywhere in the IsilDump — the
/// client only ever writes it — so the key's presence is what matters.</summary>
private static object ToVersionWire(
InventionVersionEntity v, int chipsCost, int cloudVariablesCost) => new
{
v.InventionId,
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
@@ -1014,6 +1233,34 @@ public class InventionsController(
v.VersionNumber,
v.InstantiationCost,
v.LightsCost,
ChipsCost = chipsCost,
CloudVariablesCost = cloudVariablesCost,
v.BlobName,
BlobHash = string.Empty,
};
/// <summary>Every invention MUTATION route answers with the
/// Status/Invention/InventionVersion envelope, never a bare invention: the
/// 2023 client deserialises <c>BDNCJIPHHOK</c> (key literals at
/// GMBGBPNMGBA.txt:044-095) and the 2020.12 watch deserialises
/// <c>AHEPPAEOLOD</c>, whose reader pulls the same three literals
/// (2020.12 IsilDump AHEPPAEOLOD.txt:110/115/120). Returning the invention
/// flat left <c>Invention</c> null on 2023 and threw KeyNotFoundException on
/// December. <c>Status = 0</c> is success —
/// EnterInventionNameDialog.txt:622-624 loads the boxed Status field and
/// does <c>test eax,eax / je &lt;success&gt;</c>.</summary>
private async Task<object> EnvelopeAsync(InventionEntity inv)
{
var current = await db.InventionVersions
.Where(x => x.InventionId == inv.Id)
.OrderByDescending(x => x.VersionNumber)
.FirstOrDefaultAsync();
object? versionWire = current is null ? null : ToVersionWire(current);
return new
{
Status = 0,
Invention = ToWire(inv),
InventionVersion = versionWire,
};
}
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
@@ -11,8 +12,22 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerEvents;
/// <summary>
/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for
/// player-scheduled gatherings.
/// api.rec.net/api/playerevents/v1|v2/* — create, browse, edit, RSVP and
/// broadcast for player-scheduled gatherings.
///
/// The 2023-03-21 client's event API surface is <c>RecNet.Runtime/CBKANFIOBCF</c>
/// (route literals at <c>CBKANFIOBCF.txt:161-4156</c>). Three wire types drive
/// nearly every handler here:
/// * <b>HPIOAGDJHDH</b> (PlayerEvent) — 17 keys, formatter
/// <c>HPMMKFGDAEC.txt:1139-1518</c>.
/// * <b>MDCBEPJCJPO</b> (PlayerEvent + <c>Tags:[{Tag,Type}]</c>) — formatter
/// <c>CENEMCMGDKG.txt:1191-1586</c>, tag entry keys
/// <c>PCMLHLIBLNJ.txt:191-218</c>.
/// * <b>PHHAKLPGNGC</b> (<c>{PlayerEvent,Result,TagModifyResult}</c>) —
/// formatter <c>EEAAGIJHLOJ.txt:267-326</c>, <c>TagModifyResult</c> keys
/// <c>ALJPHDEAHBK.txt:191-218</c>. Every v2 create/edit/delete/field
/// mutation deserialises this wrapper, so a flat event body leaves the
/// client's <c>PlayerEvent</c> null after a save.
/// </summary>
[ApiController]
[Authorize]
@@ -22,14 +37,23 @@ public class PlayerEventsController(
{
private long Me => this.RequireCurrentPlayerId();
// ── Browse (v2) ──────────────────────────────────────────────────────
/// <summary>PlayerEventResponseType.NotGoing — the one RSVP state that
/// does not count towards AttendeeCount.</summary>
private const int NotGoingResponse = 2;
/// <summary>GET <c>api/playerevents/v2</c> — upcoming events list.
/// Returns the next 50 events ordered by start time. Wire shape
/// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId,
/// Title, Description, StartsAt, EndsAt, Capacity).</summary>
// ── Browse ───────────────────────────────────────────────────────────
/// <summary>GET <c>api/playerevents/v2</c> (and <c>v1</c>) — upcoming
/// events list, a BARE JSON array of PlayerEvent.
///
/// The 2023 watch's Events tab reads the unversioned path: HIHEFBMPOGC
/// returns <c>List&lt;HPIOAGDJHDH&gt;</c> and dispatches with verb 0
/// (<c>CBKANFIOBCF.txt:1305</c>, route literal <c>:1304</c>). Only
/// <c>[HttpPost]</c> was registered on <c>api/playerevents/v1</c>, so the
/// whole tab 405'd.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v2")]
[HttpGet("api/playerevents/v1")]
public async Task<ActionResult> ListUpcoming(
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
@@ -41,9 +65,17 @@ public class PlayerEventsController(
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
return Ok(await ToWireManyAsync(rows));
}
/// <summary>GET <c>api/playerevents/v1|v2/{id}</c>. The client calls this
/// three ways — plain (NLEFILEAMIP → HPIOAGDJHDH) and with
/// <c>?includeDetails=</c> / <c>?clubId=</c> (HLJBCGMONEH / OAKKDBLNKLG →
/// MDCBEPJCJPO, query keys at
/// <c>CBKANFIOBCF_NestedType_DEIBMMABHIK.txt:86,99</c>). Both DTOs are the
/// same object, the detail one just adds <c>Tags</c>, and the Utf8Json
/// formatters skip unknown keys — so we always emit the superset and no
/// branch is needed.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v2/{eventId:long}")]
[HttpGet("api/playerevents/v1/{eventId:long}")]
@@ -51,23 +83,28 @@ public class PlayerEventsController(
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
return Ok(ToWire(ev));
return Ok(await ToWireOneAsync(ev));
}
/// <summary>GET <c>api/playerevents/v1/search</c>. Query keys are
/// <c>query</c>, <c>sort</c> and <c>scheduleFilter</c>
/// (<c>CBKANFIOBCF_NestedType_IONCOJJGHNJ.txt:99,113,126</c>); the latter
/// two are enum ordinals (PJBLEKMMACM / BJBLPLKMLBE).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/search")]
[HttpGet("api/playerevents/v1/searchlive")]
public async Task<ActionResult> Search(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int? sort = null,
[FromQuery] int? scheduleFilter = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 50)
{
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var rows = db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow);
var rows = ApplyScheduleFilter(db.PlayerEvents.AsQueryable(), scheduleFilter);
if (needle.Length > 0)
{
rows = rows.Where(e =>
@@ -75,12 +112,70 @@ public class PlayerEventsController(
e.Description.ToLower().Contains(needle));
}
var result = await rows
.OrderBy(e => e.StartsAt)
var result = await ApplySort(rows, sort)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(result.Select(ToWire));
return Ok(await ToWireManyAsync(result));
}
/// <summary>GET <c>api/playerevents/v1/searchlive</c> — "happening now".
/// Separate handler from <see cref="Search"/> because the response element
/// type differs: FIKHCEPDIBF returns <c>List&lt;CLDDIKOJMAM&gt;</c>, which
/// is the PlayerEvent object plus <c>PlayerCount</c> (Int32) and
/// <c>IsFull</c> (Boolean) — formatter <c>PDIFGFKAMBG.txt:1267,1294</c>.
/// Head-count comes from the room's live GameSessions rows (their RoomId is
/// the numeric room id as a string, see
/// <c>Services/GameSessionService.cs:91-96</c>).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/searchlive")]
public async Task<ActionResult> SearchLive(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int take = 50)
{
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
take = Math.Clamp(take, 1, 100);
var now = DateTime.UtcNow;
var live = db.PlayerEvents.Where(e => e.StartsAt <= now && e.EndsAt > now);
if (needle.Length > 0)
{
live = live.Where(e =>
e.Title.ToLower().Contains(needle) ||
e.Description.ToLower().Contains(needle));
}
var rows = await live.OrderBy(e => e.StartsAt).Take(take).ToListAsync();
var wire = await ToWireManyAsync(rows);
var roomKeys = rows.Select(r => r.RoomId.ToString()).Distinct().ToList();
var sessions = await db.GameSessions
.Where(s => roomKeys.Contains(s.RoomId))
.GroupBy(s => s.RoomId)
.Select(g => new
{
RoomId = g.Key,
Players = g.Sum(s => s.PlayerCount),
Capacity = g.Sum(s => s.MaxCapacity),
})
.ToListAsync();
var byRoom = sessions.ToDictionary(s => s.RoomId, s => s);
for (var i = 0; i < rows.Count; i++)
{
var players = 0;
var capacity = 0;
if (byRoom.TryGetValue(rows[i].RoomId.ToString(), out var agg))
{
players = agg.Players;
capacity = agg.Capacity;
}
wire[i]["PlayerCount"] = players;
wire[i]["IsFull"] = capacity > 0 && players >= capacity;
}
return Ok(wire);
}
[AllowAnonymous]
@@ -92,53 +187,53 @@ public class PlayerEventsController(
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
return Ok(await ToWireManyAsync(rows));
}
/// <summary>GET <c>api/playerevents/v1/club/{clubId}</c>. PHNEDJAPIKI
/// returns IOKLNPFOLGI — an OBJECT
/// <c>{Events:[PlayerEvent],ContinuationToken:String}</c>
/// (<c>GHBIGGJKPFI.txt:203-238</c>), not a bare array, and passes
/// <c>take</c> / <c>continuationToken</c> query params
/// (<c>CBKANFIOBCF_NestedType_PKDDOCBDBIM.txt:80,89</c>). The token here is
/// just the next skip offset; empty string means "no more pages" (the
/// client's field is a plain String, so never emit null).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
public async Task<ActionResult> ForClub(long clubId)
public async Task<ActionResult> ForClub(
long clubId,
[FromQuery] int? take = null,
[FromQuery] string? continuationToken = null)
{
var memberIds = await db.ClubMemberships
.Where(m => m.ClubId == clubId)
.Select(m => m.PlayerId)
.ToListAsync();
if (memberIds.Count == 0) return Ok(Array.Empty<object>());
var pageSize = Math.Clamp(take ?? 50, 1, 100);
var skip = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
var roomIds = await db.Rooms
.Where(r => memberIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
if (roomIds.Count == 0) return Ok(Array.Empty<object>());
// Fetch one extra row to find out whether another page exists.
var rows = await ClubEventsAsync(new[] { clubId }, skip, pageSize + 1);
var hasMore = rows.Count > pageSize;
if (hasMore) rows.RemoveAt(rows.Count - 1);
var rows = await db.PlayerEvents
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
return Ok(new
{
Events = await ToWireManyAsync(rows),
ContinuationToken = hasMore ? (skip + pageSize).ToString() : string.Empty,
});
}
/// <summary>GET <c>api/playerevents/v1/clubs</c> — events for a specific
/// set of clubs. IEGLMOOPLED appends one repeated <c>id</c> query value per
/// club (<c>CBKANFIOBCF_NestedType_LKJFABHPIAN.txt:59</c>); ignoring them
/// returned every club's events and the watch could not bucket the list.
/// </summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/clubs")]
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var clubPlayerIds = await db.ClubMemberships
.Select(m => m.PlayerId)
.Distinct()
.ToListAsync();
if (clubPlayerIds.Count == 0) return Ok(Array.Empty<object>());
var roomIds = await db.Rooms
.Where(r => clubPlayerIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
var rows = await db.PlayerEvents
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
var clubIds = new List<long>();
foreach (var value in Request.Query["id"]) AddDelimitedIds(clubIds, value);
var rows = await ClubEventsAsync(clubIds.Distinct().ToList(), 0, take);
return Ok(await ToWireManyAsync(rows));
}
public sealed class EventBulkRequest
@@ -148,6 +243,10 @@ public class PlayerEventsController(
public List<long>? Ids { get; set; }
}
/// <summary>POST <c>api/playerevents/v1/bulk</c> — verb 2 at
/// <c>CBKANFIOBCF.txt:660</c>; the form field is <c>Ids</c>
/// (<c>CBKANFIOBCF_NestedType_PIKPJPBHOAC.txt:64</c>). GET is kept for the
/// 2020 client, which puts the ids in the query string.</summary>
[AllowAnonymous]
[HttpPost("api/playerevents/v1/bulk")]
[HttpGet("api/playerevents/v1/bulk")]
@@ -158,21 +257,29 @@ public class PlayerEventsController(
var rows = await db.PlayerEvents
.Where(e => ids.Contains(e.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
return Ok(await ToWireManyAsync(rows));
}
/// <summary>GET <c>api/playerevents/v1/tagfilters</c> — AKCLLEJNFFD reads
/// three String lists: <c>PinnedFilters</c>, <c>PopularFilters</c> and
/// <c>TrendingFilters</c> (<c>HMDONHHKKGA.txt:279-346</c>). A missing key
/// leaves the list null and the chips UI NREs when it enumerates.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/tagfilters")]
public IActionResult TagFilters() => Ok(new
{
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
TrendingFilters = new[] { "game", "social", "competition", "class", "club" },
});
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
/// events). Response keys (both required, via
/// <c>Util.GetObjectListKey</c>): <c>Created</c>, <c>Responses</c>.</summary>
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd events).
/// PCELKKHNPHJ is <c>{Created:[PlayerEvent], Responses:[COAGAJPELCG]}</c>
/// (<c>ENJDEONHNMO.txt:191-218</c>) where each response entry is
/// <c>{PlayerEvent, PlayerEventResponse}</c> (<c>HJKLIIPPPEM.txt:215-258</c>)
/// — a flat response row leaves <c>PlayerEvent</c> null and the My Events
/// page NREs.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all")]
public async Task<ActionResult> AllPlayerEvents()
@@ -180,99 +287,477 @@ public class PlayerEventsController(
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Ok(new { Created = Array.Empty<object>(), Responses = Array.Empty<object>() });
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == me)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == me)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
return await AllForPlayerAsync(me);
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
public async Task<ActionResult> AllPlayerEventsForAccount(long accountId)
public Task<ActionResult> AllPlayerEventsForAccount(long accountId)
=> AllForPlayerAsync(accountId);
private async Task<ActionResult> AllForPlayerAsync(long playerId)
{
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == accountId)
.Where(e => e.CreatorPlayerId == playerId)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.Take(200)
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == accountId)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.Where(r => r.PlayerId == playerId)
.OrderByDescending(r => r.CreatedAt)
.Take(200)
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
// Each response entry embeds its own event, which may not be one the
// caller created — pull those in too so PlayerEvent is never null.
var createdIds = created.Select(e => e.Id).ToHashSet();
var missingIds = responses.Select(r => r.EventId).Where(id => !createdIds.Contains(id)).Distinct().ToList();
var extraEvents = new List<PlayerEventEntity>();
if (missingIds.Count > 0)
extraEvents = await db.PlayerEvents.Where(e => missingIds.Contains(e.Id)).ToListAsync();
var all = created.Concat(extraEvents).ToList();
var wire = await ToWireMapAsync(all);
return Ok(new
{
Created = created.Select(e => wire[e.Id]).ToList(),
Responses = responses
.Where(r => wire.ContainsKey(r.EventId))
.Select(r => new
{
PlayerEvent = wire[r.EventId],
PlayerEventResponse = ResponseWire(r),
})
.ToList(),
});
}
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
/// as <c>v1/{id}/rsvp</c>; behaves identically.</summary>
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
=> Rsvp(eventId, body);
/// <summary>Wire shape verified against
/// <c>Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815</c>:
/// PascalCase keys <c>PlayerEventId, Name, Description,
/// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId,
/// ImageName, Accessibility</c>. Server-side we store these
/// under different names (Title, StartsAt, EndsAt, Capacity) —
/// remap explicitly here so the watch's
/// <c>PlayerEvent.Deserialize</c> picks them up.</summary>
private static object ToWire(PlayerEventEntity ev) => new
/// <summary>GET <c>api/playerevents/v1/{eventId}/responses</c> — the event
/// detail page's attendee list. DFABBIDLDPE dispatches with verb 0
/// (<c>CBKANFIOBCF.txt:1699</c>, route literal <c>:1689</c>) and reads
/// <c>List&lt;JPIKCIGABBI&gt;</c>; the path was registered POST-only, so
/// the list 405'd.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/{eventId:long}/responses")]
public async Task<ActionResult> ResponsesForEvent(long eventId)
{
PlayerEventId = ev.Id,
Name = ev.Title,
ev.Description,
StartTime = ev.StartsAt,
EndTime = ev.EndsAt,
CreatorPlayerId = (int)ev.CreatorPlayerId,
AttendeeCount = 0, // computed at query time when needed
ev.RoomId,
ImageName = string.Empty,
Accessibility = 1, // Public
var rows = await db.PlayerEventResponses
.Where(r => r.EventId == eventId)
.OrderBy(r => r.CreatedAt)
.Take(500)
.ToListAsync();
return Ok(rows.Select(ResponseWire).ToList());
}
// ── Wire shape ───────────────────────────────────────────────────────
/// <summary>The PlayerEvent fields the 2023 client carries that
/// <see cref="PlayerEventEntity"/> has no column for. Adding columns needs
/// a migration in <c>Data/Entities</c>, so these live in the PlayerSettings
/// side-table: one JSON row per event under
/// <c>playerevent:{id}:wire</c>, plus a flat <c>playerevent:{id}:club</c>
/// row that keeps club lookups a single indexed query.
///
/// Public (not private) purely so System.Text.Json's reflection-based
/// (de)serialiser can see the type and its accessors.</summary>
public sealed class EventExtras
{
public long? SubRoomId { get; set; }
public long? ClubId { get; set; }
public string ImageName { get; set; } = string.Empty;
/// <summary>BAMLHAODDOG ordinal. The enum's member order is not
/// recoverable from the ISIL (enums carry no method bodies), so events
/// that never had accessibility set keep the historical default of 1
/// rather than guessing a new one; once the client PUTs a value we
/// echo exactly what it sent.</summary>
public int Accessibility { get; set; } = 1;
public bool IsMultiInstance { get; set; }
public bool SupportMultiInstanceRoomChat { get; set; }
public int DefaultBroadcastPermissions { get; set; }
public int CanRequestBroadcastPermissions { get; set; }
public long? BroadcastingRoomInstanceId { get; set; }
public List<string> Tags { get; set; } = [];
}
private static string ExtrasKey(long eventId) => $"playerevent:{eventId}:wire";
private static string ClubKey(long eventId) => $"playerevent:{eventId}:club";
/// <summary>Wire shape verified against the client's Utf8Json formatter
/// <c>HPMMKFGDAEC.txt:1139-1518</c> (and the details variant
/// <c>CENEMCMGDKG.txt:1191-1586</c>): PascalCase keys <c>PlayerEventId,
/// CreatorPlayerId, RoomId, SubRoomId, ClubId, Name, Description,
/// ImageName, StartTime, EndTime, AttendeeCount, Accessibility,
/// IsMultiInstance, SupportMultiInstanceRoomChat,
/// DefaultBroadcastPermissions, CanRequestBroadcastPermissions,
/// BroadcastingRoomInstanceId</c> — property types read off the getters in
/// <c>HPIOAGDJHDH.txt</c>. Server-side we store some of these under other
/// names (Title, StartsAt, EndsAt), so remap explicitly.</summary>
private static Dictionary<string, object?> ToWire(
PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new()
{
["PlayerEventId"] = ev.Id,
["CreatorPlayerId"] = (int)ev.CreatorPlayerId,
["RoomId"] = ev.RoomId,
["SubRoomId"] = extras.SubRoomId,
["ClubId"] = extras.ClubId,
["Name"] = ev.Title,
["Description"] = ev.Description,
["ImageName"] = extras.ImageName,
["StartTime"] = ev.StartsAt,
["EndTime"] = ev.EndsAt,
["AttendeeCount"] = attendeeCount,
["Accessibility"] = extras.Accessibility,
["IsMultiInstance"] = extras.IsMultiInstance,
["SupportMultiInstanceRoomChat"] = extras.SupportMultiInstanceRoomChat,
["DefaultBroadcastPermissions"] = extras.DefaultBroadcastPermissions,
["CanRequestBroadcastPermissions"] = extras.CanRequestBroadcastPermissions,
["BroadcastingRoomInstanceId"] = extras.BroadcastingRoomInstanceId,
// MDCBEPJCJPO (the includeDetails DTO) is this same object plus
// Tags:[{Tag,Type}] (entry keys PCMLHLIBLNJ.txt:191-218). The tagless
// formatter skips unknown keys, so emitting Tags unconditionally is
// inert there and saves branching on includeDetails. Type is the
// KIKIEHKBHNM ordinal — we only ever store free-form tags, so 0.
["Tags"] = extras.Tags.Select(t => new { Tag = t, Type = 0 }).ToList(),
};
/// <summary>JPIKCIGABBI — <c>{PlayerEventResponseId, PlayerEventId,
/// PlayerId(Int32), CreatedAt, Type}</c>, keys at
/// <c>DDDLCGDEPNH.txt:395-502</c>, types off the getters in
/// <c>JPIKCIGABBI.txt</c>.</summary>
private static object ResponseWire(PlayerEventResponseEntity r) => new
{
PlayerEventResponseId = r.Id,
PlayerEventId = r.EventId,
PlayerId = (int)r.PlayerId,
r.CreatedAt,
Type = r.Response,
};
/// <summary>PHHAKLPGNGC — the wrapper every v2 mutation deserialises.
/// </summary>
private static object Wrapper(PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new
{
PlayerEvent = ToWire(ev, extras, attendeeCount),
Result = 0, // BNCFHOOCHAI success
TagModifyResult = new { Result = 0, Tags = extras.Tags },
};
private async Task<object> WrapperAsync(PlayerEventEntity ev)
{
var extras = (await LoadExtrasAsync(new[] { ev }))[ev.Id];
var counts = await AttendeeCountsAsync(new[] { ev.Id });
return Wrapper(ev, extras, counts.GetValueOrDefault(ev.Id));
}
private async Task<List<Dictionary<string, object?>>> ToWireManyAsync(IReadOnlyList<PlayerEventEntity> rows)
{
if (rows.Count == 0) return [];
var extras = await LoadExtrasAsync(rows);
var counts = await AttendeeCountsAsync(rows.Select(r => r.Id).ToList());
return rows
.Select(r => ToWire(r, extras[r.Id], counts.GetValueOrDefault(r.Id)))
.ToList();
}
private async Task<Dictionary<long, Dictionary<string, object?>>> ToWireMapAsync(
IReadOnlyList<PlayerEventEntity> rows)
{
var wire = await ToWireManyAsync(rows);
var map = new Dictionary<long, Dictionary<string, object?>>();
for (var i = 0; i < rows.Count; i++) map[rows[i].Id] = wire[i];
return map;
}
private async Task<Dictionary<string, object?>> ToWireOneAsync(PlayerEventEntity ev)
=> (await ToWireManyAsync(new[] { ev }))[0];
private async Task<Dictionary<long, EventExtras>> LoadExtrasAsync(IReadOnlyCollection<PlayerEventEntity> events)
{
var map = new Dictionary<long, EventExtras>();
foreach (var ev in events) map[ev.Id] = new EventExtras();
if (map.Count == 0) return map;
var keys = map.Keys.Select(ExtrasKey).ToList();
var rows = await db.PlayerSettings
.AsNoTracking()
.Where(s => keys.Contains(s.Key))
.Select(s => new { s.Key, s.Value })
.ToListAsync();
foreach (var row in rows)
{
if (EventIdFromKey(row.Key) is not long id || !map.ContainsKey(id)) continue;
var parsed = DeserializeExtras(row.Value);
if (parsed is not null) map[id] = parsed;
}
return map;
}
private static long? EventIdFromKey(string key)
{
var parts = key.Split(':');
return parts.Length >= 3 && long.TryParse(parts[1], out var id) ? id : null;
}
private static EventExtras? DeserializeExtras(string json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
return JsonSerializer.Deserialize<EventExtras>(json);
}
catch (JsonException)
{
return null;
}
}
private async Task SaveExtrasAsync(PlayerEventEntity evt, EventExtras extras)
{
// PlayerSettingEntity.Value is MaxLength(1024); bound the tag list so a
// long one can never truncate the JSON blob into unparseable garbage.
if (extras.Tags.Count > 12) extras.Tags = extras.Tags.Take(12).ToList();
extras.Tags = extras.Tags.Select(t => Clamp(t, 32)).ToList();
await UpsertSettingAsync(evt.CreatorPlayerId, ExtrasKey(evt.Id),
JsonSerializer.Serialize(extras));
// Flat index row so "events for club X" stays one indexed lookup
// instead of a scan through the JSON blobs.
await UpsertSettingAsync(evt.CreatorPlayerId, ClubKey(evt.Id),
extras.ClubId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
private async Task UpsertSettingAsync(long playerId, string key, string value)
{
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = playerId,
Key = key,
Value = value,
});
}
else
{
row.Value = value;
}
}
private async Task<Dictionary<long, int>> AttendeeCountsAsync(IReadOnlyCollection<long> eventIds)
{
if (eventIds.Count == 0) return new Dictionary<long, int>();
var ids = eventIds.ToList();
var rows = await db.PlayerEventResponses
.Where(r => ids.Contains(r.EventId) && r.Response != NotGoingResponse)
.GroupBy(r => r.EventId)
.Select(g => new { EventId = g.Key, Count = g.Count() })
.ToListAsync();
return rows.ToDictionary(r => r.EventId, r => r.Count);
}
// ── Query shaping ────────────────────────────────────────────────────
/// <summary>'scheduleFilter' (BJBLPLKMLBE) is an enum ordinal. Only the
/// query-key literal survives in the ISIL — enums have no method bodies to
/// disassemble — so the member order below is UNVERIFIED. Ordinal 0 (and
/// absent) reproduces the previous behaviour exactly, and a wrong guess for
/// the others still returns a valid, non-empty event list rather than an
/// error.</summary>
private static IQueryable<PlayerEventEntity> ApplyScheduleFilter(
IQueryable<PlayerEventEntity> rows, int? scheduleFilter)
{
var now = DateTime.UtcNow;
return scheduleFilter switch
{
1 => rows.Where(e => e.StartsAt <= now && e.EndsAt > now),
2 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(1)),
3 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(7)),
_ => rows.Where(e => e.EndsAt > now),
};
}
/// <summary>'sort' (PJBLEKMMACM) — same UNVERIFIED-ordinal caveat as
/// <see cref="ApplyScheduleFilter"/>; 0/absent keeps soonest-first.</summary>
private static IQueryable<PlayerEventEntity> ApplySort(
IQueryable<PlayerEventEntity> rows, int? sort) => sort switch
{
1 => rows.OrderByDescending(e => e.CreatedAt),
2 => rows.OrderBy(e => e.EndsAt),
_ => rows.OrderBy(e => e.StartsAt),
};
/// <summary>Events attached to any of <paramref name="clubIds"/>. Explicit
/// ClubId (persisted since the v2 create/edit body carries it) wins; when
/// nothing carries one we fall back to the historical heuristic of "events
/// in rooms owned by club members" so pre-existing data still populates the
/// tab.</summary>
private async Task<List<PlayerEventEntity>> ClubEventsAsync(
IReadOnlyCollection<long> clubIds, int skip, int take)
{
var explicitIds = await EventIdsForClubsAsync(clubIds);
IQueryable<PlayerEventEntity> q;
if (explicitIds.Count > 0)
{
q = db.PlayerEvents.Where(e => explicitIds.Contains(e.Id) && e.EndsAt > DateTime.UtcNow);
}
else
{
var roomIds = await ClubRoomIdsAsync(clubIds);
if (roomIds.Count == 0) return [];
q = db.PlayerEvents.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow);
}
return await q.OrderBy(e => e.StartsAt).Skip(skip).Take(take).ToListAsync();
}
private async Task<List<long>> EventIdsForClubsAsync(IReadOnlyCollection<long> clubIds)
{
if (clubIds.Count == 0) return [];
var wanted = clubIds.Select(c => c.ToString(CultureInfo.InvariantCulture)).ToList();
var keys = await db.PlayerSettings
.AsNoTracking()
.Where(s => wanted.Contains(s.Value)
&& s.Key.StartsWith("playerevent:")
&& s.Key.EndsWith(":club"))
.Select(s => s.Key)
.ToListAsync();
return keys
.Select(EventIdFromKey)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
}
private async Task<List<long>> ClubRoomIdsAsync(IReadOnlyCollection<long> clubIds)
{
var memberships = db.ClubMemberships.AsQueryable();
if (clubIds.Count > 0)
{
var ids = clubIds.ToList();
memberships = memberships.Where(m => ids.Contains(m.ClubId));
}
var memberIds = await memberships.Select(m => m.PlayerId).Distinct().ToListAsync();
if (memberIds.Count == 0) return [];
return await db.Rooms
.Where(r => memberIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
}
// ── Create / edit (v2) ───────────────────────────────────────────────
/// <summary>POST <c>api/playerevents/v2</c> — the 2023 create-event flow.
/// KKMMBCDGFFN posts BIEFKAOABMP as a RawJsonForm body (verb 2 at
/// <c>CBKANFIOBCF.txt:2390</c>) whose 14 keys are
/// <c>RoomId, SubRoomId, ClubId, Name, Description, Tags, ImageName,
/// StartTime, EndTime, Accessibility, IsMultiInstance,
/// SupportMultiInstanceRoomChat, DefaultBroadcastPermissions,
/// CanRequestBroadcastPermissions</c> (<c>ACKNGEEHHAE.txt:935-1234</c>),
/// and reads the PHHAKLPGNGC wrapper back. Only <c>[HttpGet]</c> was
/// registered on this path, so creating an event 405'd.</summary>
[HttpPost("api/playerevents/v2")]
public async Task<IActionResult> CreateV2()
{
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim() ?? string.Empty;
if (name.Length == 0) return BadRequest("missing_name");
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt") ?? DateTime.UtcNow;
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt") ?? start.AddHours(1);
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim() ?? string.Empty;
var evt = new PlayerEventEntity
{
CreatorPlayerId = Me,
RoomId = await ReadLongFieldAsync("RoomId", "roomId") ?? 0,
Title = Clamp(name, 128),
Description = Clamp(description, 2000),
StartsAt = start,
EndsAt = end > start ? end : start.AddHours(1),
};
db.PlayerEvents.Add(evt);
await db.SaveChangesAsync();
var extras = new EventExtras();
await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>POST <c>api/playerevents/v2/{eventId}</c> — edit-event save.
/// IOEJKIPJFOI posts the same BIEFKAOABMP body with verb 2
/// (<c>CBKANFIOBCF.txt:2595</c>, route literal <c>:2579</c>); the path was
/// GET-only, so editing 405'd.</summary>
[HttpPost("api/playerevents/v2/{eventId:long}")]
public async Task<IActionResult> EditV2(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim();
if (!string.IsNullOrEmpty(name)) evt.Title = Clamp(name, 128);
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim();
if (description is not null) evt.Description = Clamp(description, 2000);
var roomId = await ReadLongFieldAsync("RoomId", "roomId");
if (roomId is long room && room > 0) evt.RoomId = room;
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt");
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt");
if (start is DateTime s) evt.StartsAt = s;
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras);
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
return Ok(await WrapperAsync(evt));
}
/// <summary>Copies whatever subset of the BIEFKAOABMP body is present onto
/// <paramref name="extras"/>. Absent keys are left alone so the same helper
/// serves both create and edit.</summary>
private async Task ApplyBodyToExtrasAsync(EventExtras extras)
{
var fields = await ReadBodyAsync();
if (fields.Fields.ContainsKey("SubRoomId"))
extras.SubRoomId = await ReadLongFieldAsync("SubRoomId", "subRoomId");
if (fields.Fields.ContainsKey("ClubId"))
extras.ClubId = await ReadLongFieldAsync("ClubId", "clubId");
var imageName = await ReadStringFieldAsync("ImageName", "imageName");
if (imageName is not null) extras.ImageName = Clamp(imageName.Trim(), 128);
if (await ReadIntFieldAsync("Accessibility", "accessibility") is int accessibility)
extras.Accessibility = accessibility;
if (await ReadBoolFieldAsync("IsMultiInstance", "isMultiInstance") is bool multi)
extras.IsMultiInstance = multi;
if (await ReadBoolFieldAsync("SupportMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
"supportsMultiInstanceRoomChat") is bool chat)
extras.SupportMultiInstanceRoomChat = chat;
if (await ReadIntFieldAsync("DefaultBroadcastPermissions", "defaultBroadcastPermissions") is int def)
extras.DefaultBroadcastPermissions = def;
if (await ReadIntFieldAsync("CanRequestBroadcastPermissions", "canRequestBroadcastPermissions") is int req)
extras.CanRequestBroadcastPermissions = req;
var tags = await ReadStringListFieldAsync("Tags", "tags");
if (tags is not null) extras.Tags = tags;
}
// ── Create / RSVP (v1, shared with the 2020 client) ──────────────────
public sealed record CreateEventRequest(
string Title, string? Description, long RoomId,
DateTime StartsAt, DateTime EndsAt, int Capacity);
@@ -295,11 +780,7 @@ public class PlayerEventsController(
};
db.PlayerEvents.Add(ev);
await db.SaveChangesAsync();
return Ok(new
{
ev.Id, ev.Title, ev.Description, ev.RoomId,
ev.StartsAt, ev.EndsAt, ev.Capacity,
});
return Ok(await ToWireOneAsync(ev));
}
public sealed record RsvpRequest(int Response);
@@ -328,25 +809,45 @@ public class PlayerEventsController(
await notifications.NotifyAsync(ev.CreatorPlayerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, From = Me, body.Response });
// Left as the historical shape on purpose: this route is 2020-only
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
// so there is no 2023 DTO to match here.
return Ok(new { row.EventId, row.PlayerId, row.Response });
}
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
/// caller's RSVP state for an event. <c>Response</c> is the
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
/// Idempotent — re-responding overwrites.
///
/// Wire return is a BARE int (<c>DGOPHENCPOC</c> enum value:
/// 0=Success, plus various error codes). Returning an object
/// here throws InvalidCastException on the 2020.12 deserialiser
/// — verified at <c>recroom-2020-client-response-contracts.md</c>
/// under the playerevents/v1/respond section.</summary>
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the 2020 watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
/// as <c>v1/{id}/rsvp</c>; behaves identically. The 2023 client GETs this
/// path instead — see <see cref="ResponsesForEvent"/>.</summary>
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
=> Rsvp(eventId, body);
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the caller's RSVP
/// state. Two client generations share this route with different
/// contracts:
/// * 2020.12 posts a form and reads a BARE int (DGOPHENCPOC); returning
/// an object throws InvalidCastException there — see
/// <c>recroom-2020-client-response-contracts.md</c>.
/// * 2023-03 posts a RawJsonForm body <c>{PlayerEventId, Type}</c>
/// (NKIFBKJALEJ, keys at <c>GFHNJMMBHFD.txt:203-238</c>; body build at
/// <c>CBKANFIOBCF_NestedType_NFCEOKLHDAG.txt:81-112</c>) and reads
/// CEKABGOIOAF, an OBJECT <c>{Result:int}</c>
/// (<c>GJJGDENPOAP.txt:139-150</c>).
/// Branch the response shape on the body encoding the caller actually
/// used — that is the only signal that distinguishes them.</summary>
[HttpPost("api/playerevents/v1/respond")]
public async Task<IActionResult> RespondForm(
[FromForm(Name = "EventId")] long? eventId,
[FromForm(Name = "Response")] int response = 0)
public async Task<IActionResult> RespondForm()
{
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/);
var legacyForm = Request.HasFormContentType;
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt || evt <= 0) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
var response = await ReadIntFieldAsync("Type", "type", "Response", "response") ?? 0;
if (!await db.PlayerEvents.AnyAsync(e => e.Id == evt))
return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null)
@@ -354,35 +855,65 @@ public class PlayerEventsController(
else
existing.Response = response;
await db.SaveChangesAsync();
return Ok(0 /*Success*/);
return RespondResult(legacyForm, 0 /*Success*/);
}
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
/// the caller's RSVP row. Same bare-int wire shape as
/// <see cref="RespondForm"/>.</summary>
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop the
/// caller's RSVP row. Same two-generation split as
/// <see cref="RespondForm"/>. The 2023 body is
/// <c>JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest)</c>
/// (<c>CBKANFIOBCF_NestedType_MDJMHFHPOBO.txt:65-78</c>) whose single field
/// is name-preserved: <c>public long PlayerEventId</c>
/// (2023.06.21 <c>dump.cs:1291301</c>).</summary>
[HttpPost("api/playerevents/v1/deleteResponse")]
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
public async Task<IActionResult> DeleteResponseForm()
{
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/);
var legacyForm = Request.HasFormContentType;
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
return Ok(0 /*Success*/);
return RespondResult(legacyForm, 0 /*Success*/);
}
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
/// delete an event the caller created.</summary>
private IActionResult RespondResult(bool legacyForm, int code)
=> legacyForm ? Ok(code) : Ok(new { Result = code });
/// <summary>DELETE/POST <c>api/playerevents/v2/delete/{id}</c> — hard
/// delete an event the caller created. GJMCEDALKJO dispatches with verb 2
/// (<c>CBKANFIOBCF.txt:4166</c>, route literal <c>:4156</c>) and reads the
/// PHHAKLPGNGC wrapper, so POST plus the wrapper body are both required;
/// DELETE stays for the 2020 client.</summary>
[HttpPost("api/playerevents/v2/delete/{id:long}")]
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
public async Task<IActionResult> DeleteByPath(long id)
{
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me);
if (evt is null) return NotFound();
// Snapshot the wire shape before the rows go away — the wrapper still
// has to echo the event that was deleted.
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
.ExecuteDeleteAsync();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
return Ok(Wrapper(evt, extras, attendees));
}
// ── Field mutations (v2) ─────────────────────────────────────────────
// Every one of these is a PUT (verb 3, e.g. CBKANFIOBCF.txt:2744) whose
// fields arrive as x-www-form-urlencoded, and every one returns the
// PHHAKLPGNGC wrapper — a flat event body leaves the client's PlayerEvent
// null right after a successful save. POST is kept alongside for the 2020
// client.
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
public async Task<IActionResult> UpdateName(long eventId)
@@ -391,10 +922,10 @@ public class PlayerEventsController(
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("name", "Name", "value");
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)];
evt.Title = Clamp(value.Trim(), 128);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
@@ -404,12 +935,17 @@ public class PlayerEventsController(
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)];
evt.Description = Clamp(value.Trim(), 2000);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/room</c> — FPPNHCMPAKK sends
/// <c>roomId</c> AND an optional <c>subRoomId</c>
/// (<c>CBKANFIOBCF_NestedType_AKPPDNLCACI.txt:88,101</c>); dropping the
/// latter meant the event never pointed at the sub-room the host picked.
/// </summary>
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
public async Task<IActionResult> UpdateRoom(long eventId)
@@ -420,9 +956,11 @@ public class PlayerEventsController(
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
evt.RoomId = id;
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
@@ -438,7 +976,7 @@ public class PlayerEventsController(
if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
@@ -448,8 +986,10 @@ public class PlayerEventsController(
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
await SetEventSettingAsync(evt, "image", value.Trim());
return Ok(ToWire(evt));
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.ImageName = Clamp(value.Trim(), 128);
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
@@ -460,44 +1000,92 @@ public class PlayerEventsController(
if (evt is null) return NotFound();
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
if (value is not int accessibility) return BadRequest("missing_accessibility");
await SetEventSettingAsync(evt, "accessibility", accessibility.ToString());
return Ok(ToWire(evt));
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Accessibility = accessibility;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/tags</c>. JHLMDMGELFD does NOT
/// send form fields — it serialises <c>List&lt;String&gt;</c> straight into
/// a RawJsonForm body, i.e. a BARE JSON ARRAY
/// (<c>CBKANFIOBCF_NestedType_PNFGKCKCGEL.txt:63-81</c>). The old field
/// reader only looked inside JSON objects, so every save stored an empty
/// string and silently cleared the tags. The wrapper's
/// <c>TagModifyResult.Tags</c> is what the client re-reads afterwards.
/// </summary>
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
public async Task<IActionResult> UpdateTags(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty;
await SetEventSettingAsync(evt, "tags", value.Trim());
return Ok(ToWire(evt));
var tags = await ReadStringListFieldAsync("tags", "Tags", "value") ?? new List<string>();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Tags = tags;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/multiinstance</c>. IEGDKKAKHBD
/// sends FOUR form fields — <c>isMultiInstance</c>,
/// <c>supportsMultiInstanceRoomChat</c>, <c>defaultBroadcastPermissions</c>,
/// <c>canRequestBroadcastPermissions</c>
/// (<c>CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:122-161</c>). The handler used
/// to look for a field named <c>multiInstance</c>, which the client never
/// sends, so the toggle always stored false and the other three were
/// dropped. Note the request spells it "supports…" while the response key
/// is "Support…" — both spellings are accepted here.</summary>
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
public async Task<IActionResult> UpdateMultiInstance(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value");
await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false");
return Ok(ToWire(evt));
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.IsMultiInstance =
await ReadBoolFieldAsync("isMultiInstance", "IsMultiInstance", "multiInstance", "value") ?? false;
extras.SupportMultiInstanceRoomChat =
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
"SupportMultiInstanceRoomChat") ?? false;
extras.DefaultBroadcastPermissions =
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0;
extras.CanRequestBroadcastPermissions =
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/club</c>. JLHJHGANFDM takes a
/// <c>Nullable&lt;Int64&gt;</c> and boxes it into the single form field
/// <c>clubId</c> (<c>CBKANFIOBCF_NestedType_DJLDOCJBFPO.txt:60-71</c>) — a
/// null therefore arrives as an absent/empty field, which is the client's
/// only way to DETACH the club. Rejecting that with 400 made detaching
/// impossible.</summary>
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
public async Task<IActionResult> UpdateClub(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
if (clubId is not long id || id <= 0) return BadRequest("missing_club");
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
return Forbid();
await SetEventSettingAsync(evt, "club", id.ToString());
return Ok(ToWire(evt));
if (clubId is long id && id > 0)
{
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
return Forbid();
extras.ClubId = id;
}
else
{
extras.ClubId = null;
}
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
[HttpDelete("api/playerevents/v1/{eventId:long}")]
@@ -512,10 +1100,13 @@ public class PlayerEventsController(
var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId);
db.PlayerEventResponses.RemoveRange(responses);
await db.SaveChangesAsync();
await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
.ExecuteDeleteAsync();
return Ok();
}
// ── Bulk-invite (Phase 8) ────────────────────────────────────────────
// ── Bulk-invite ──────────────────────────────────────────────────────
public sealed class BulkInviteRequest
{
@@ -523,12 +1114,17 @@ public class PlayerEventsController(
public List<int>? InvitedPlayerIds { get; set; }
}
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c> — wire
/// type per agent ISIL extraction: <c>BulkInviteRequest{PlayerEventId,
/// InvitedPlayerIds:List&lt;int&gt;}</c>; response
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
/// We insert one MessageEntity per recipient so the watch's inbox
/// shows the invite, and push a per-recipient notification.</summary>
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c>. Request is
/// <c>JsonUtility.ToJson(RecNet.Events.BulkInviteRequest)</c> —
/// name-preserved fields <c>PlayerEventId</c> / <c>InvitedPlayerIds</c>
/// (2023.06.21 <c>dump.cs:1288323-1288324</c>, body build at
/// <c>CBKANFIOBCF_NestedType_MIKAHKKCIEE.txt:69-96</c>). Response is
/// IDELKAJIOLI <c>{FailedInvites, Result}</c>
/// (<c>GAODOHODBDE.txt:203-238</c>) where each failed entry is
/// <c>{InvitedPlayerId, Result}</c> (<c>MOJLGDKFECO.txt:203-238</c>) — the
/// entries used to be <c>{PlayerId, Error}</c> and deserialised as all
/// defaults. Error paths return the same JSON shape rather than a
/// NotFound()/Forbid() body the client cannot parse.</summary>
[HttpPost("api/playerevents/v1/bulkInvite")]
public async Task<IActionResult> BulkInvite([FromBody] BulkInviteRequest req)
{
@@ -536,8 +1132,11 @@ public class PlayerEventsController(
return Ok(new { FailedInvites = Array.Empty<object>(), Result = 0 });
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
// BNCFHOOCHAI's member order isn't recoverable (enums have no method
// bodies to disassemble); any non-zero code reads as "failed" on the
// client, which is all the invite sheet branches on.
if (ev is null) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
if (ev.CreatorPlayerId != Me) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
var failed = new List<object>();
var sender = Me;
@@ -547,7 +1146,7 @@ public class PlayerEventsController(
var exists = await db.Players.AnyAsync(p => p.Id == rid);
if (!exists)
{
failed.Add(new { PlayerId = rid, Error = "unknown player" });
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
continue;
}
db.Messages.Add(new MessageEntity
@@ -564,45 +1163,47 @@ public class PlayerEventsController(
return Ok(new { FailedInvites = failed, Result = 0 });
}
public sealed class BroadcastRequest
/// <summary>POST <c>api/playerevents/v1/broadcast</c> — set or clear the
/// room instance an event is being broadcast from. JBHPMONELAO posts
/// <c>RecNet.Events.BroadcastRoomInstanceRequest</c>
/// (<c>CBKANFIOBCF_NestedType_EHHMGKFGGCC.txt:81</c>) whose JSON keys are
/// <c>PlayerEventId</c> and the nullable <c>BroadcastRoomInstanceId</c>
/// (<c>EOFEKNIHNNC.txt:215-258</c>), and reads the PHHAKLPGNGC wrapper.
/// This used to be implemented as a text-message blast to every RSVP,
/// which both spammed attendees and returned an unparseable body.</summary>
[HttpPost("api/playerevents/v1/broadcast")]
public async Task<IActionResult> Broadcast()
{
public long PlayerEventId { get; set; }
public string? Message { get; set; }
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long id) return BadRequest("missing_event");
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id);
if (evt is null) return NotFound();
if (evt.CreatorPlayerId != Me) return Forbid();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.BroadcastingRoomInstanceId = await ReadLongFieldAsync(
"BroadcastRoomInstanceId", "broadcastRoomInstanceId",
"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);
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v1/broadcast")]
public async Task<IActionResult> Broadcast([FromBody] BroadcastRequest req)
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
var recipients = await db.PlayerEventResponses
.Where(r => r.EventId == ev.Id && r.Response != 2)
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
.Select(r => r.PlayerId)
.Distinct()
.ToListAsync();
var body = string.IsNullOrWhiteSpace(req.Message)
? $"Update for '{ev.Title}'"
: req.Message.Trim();
foreach (var playerId in recipients.Where(id => id != Me))
{
db.Messages.Add(new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = playerId,
Body = body,
});
await notifications.NotifyAsync(playerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, ev.Title, Message = body });
}
await db.SaveChangesAsync();
return Ok(new { Success = true, Sent = recipients.Count });
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
}
// ── Report (Phase 8) ─────────────────────────────────────────────────
// ── Report ───────────────────────────────────────────────────────────
public sealed class PlayerEventReportRequest
{
@@ -611,174 +1212,6 @@ public class PlayerEventsController(
public string? Details { get; set; }
}
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
private async Task<List<long>> ReadEventIdsAsync()
{
var ids = new List<long>();
foreach (var value in Request.Query.SelectMany(q => q.Value))
AddDelimitedIds(ids, value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
foreach (var value in form[key])
AddDelimitedIds(ids, value);
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
{
if (doc.RootElement.TryGetProperty(key, out var prop))
AddJsonIds(ids, prop);
}
}
else
{
AddJsonIds(ids, doc.RootElement);
}
}
catch (JsonException)
{
}
}
return ids.Distinct().Take(200).ToList();
}
private static void AddDelimitedIds(List<long> ids, string? value)
{
foreach (var part in (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
}
private static void AddJsonIds(List<long> ids, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
AddJsonIds(ids, item);
break;
case JsonValueKind.Number:
if (element.TryGetInt64(out var id) && id > 0) ids.Add(id);
break;
case JsonValueKind.String:
AddDelimitedIds(ids, element.GetString());
break;
}
}
private async Task<string?> ReadStringFieldAsync(params string[] names)
{
var fields = await ReadRequestFieldsAsync();
foreach (var name in names)
if (fields.TryGetValue(name, out var value))
return value;
return null;
}
private async Task<long?> ReadLongFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return long.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<int?> ReadIntFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return int.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return bool.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return DateTime.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<Dictionary<string, string>> ReadRequestFieldsAsync()
{
const string itemKey = "__playerevent_fields";
if (HttpContext.Items.TryGetValue(itemKey, out var cached)
&& cached is Dictionary<string, string> existing)
{
return existing;
}
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? string.Empty
: prop.Value.GetRawText();
}
}
catch (JsonException)
{
}
}
HttpContext.Items[itemKey] = fields;
return fields;
}
private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value)
{
var settingKey = $"playerevent:{evt.Id}:{key}";
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = evt.CreatorPlayerId,
Key = settingKey,
Value = value,
});
}
else
{
row.Value = value;
}
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
/// moderation report against an event. Wire response is
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
@@ -800,4 +1233,213 @@ public class PlayerEventsController(
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
}
// ── Request parsing ──────────────────────────────────────────────────
private static string Clamp(string value, int max)
=> value.Length <= max ? value : value[..max];
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
private async Task<List<long>> ReadEventIdsAsync()
{
var ids = new List<long>();
foreach (var value in Request.Query.SelectMany(q => q.Value))
AddDelimitedIds(ids, value);
var body = await ReadBodyAsync();
foreach (var key in new[] { "playerEventIds", "eventIds", "ids" })
{
if (body.Repeated.TryGetValue(key, out var values))
foreach (var value in values) AddDelimitedIds(ids, value);
}
if (body.RootArray is not null)
foreach (var value in body.RootArray) AddDelimitedIds(ids, value);
return ids.Distinct().Take(200).ToList();
}
private static void AddDelimitedIds(List<long> ids, string? value)
{
foreach (var part in (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
}
private async Task<string?> ReadStringFieldAsync(params string[] names)
{
var body = await ReadBodyAsync();
foreach (var name in names)
if (body.Fields.TryGetValue(name, out var value))
return value;
return null;
}
/// <summary>Reads a list-valued field. Handles all three encodings the
/// client uses: a bare JSON array body (the tags PUT), a JSON array nested
/// under a key (the v2 create/edit body's <c>Tags</c>), and repeated /
/// comma-joined form values.</summary>
private async Task<List<string>?> ReadStringListFieldAsync(params string[] names)
{
var body = await ReadBodyAsync();
if (body.RootArray is not null) return body.RootArray;
foreach (var name in names)
{
if (body.Repeated.TryGetValue(name, out var repeated) && repeated.Count > 1)
return repeated.Select(v => v.Trim()).Where(v => v.Length > 0).ToList();
if (!body.Fields.TryGetValue(name, out var raw)) continue;
raw = raw.Trim();
if (raw.Length == 0) return new List<string>();
if (raw.StartsWith('['))
{
var parsed = ParseJsonStringArray(raw);
if (parsed is not null) return parsed;
}
return raw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}
return null;
}
private static List<string>? ParseJsonStringArray(string raw)
{
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
return ElementToStrings(doc.RootElement);
}
catch (JsonException)
{
return null;
}
}
private static List<string> ElementToStrings(JsonElement array)
{
var list = new List<string>();
foreach (var item in array.EnumerateArray())
{
var value = item.ValueKind == JsonValueKind.String ? item.GetString() : item.GetRawText();
if (!string.IsNullOrWhiteSpace(value)) list.Add(value.Trim());
}
return list;
}
private async Task<long?> ReadLongFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
private async Task<int?> ReadIntFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
if (bool.TryParse(value, out var parsed)) return parsed;
// Form-encoded booleans arrive as "True"/"False" from the client's
// boxed-bool ToString, but a JSON body may carry 1/0.
return value switch { "1" => true, "0" => false, _ => null };
}
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
// The client always sends UTC (Utf8Json ISO-8601 with a Z, or
// DateTime.ToString() on an already-UTC value). Force the parse to land
// on Kind=Utc so it compares correctly against DateTime.UtcNow.
return DateTime.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
? parsed
: null;
}
/// <summary>Everything the request carries, read once and cached — the body
/// stream can only be consumed a single time.</summary>
private sealed class ParsedBody
{
public Dictionary<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, List<string>> Repeated { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Set when the whole body is a bare JSON array — the shape
/// the tags PUT sends.</summary>
public List<string>? RootArray { get; set; }
}
private const string BodyItemKey = "__playerevent_body";
private async Task<ParsedBody> ReadBodyAsync()
{
if (HttpContext.Items.TryGetValue(BodyItemKey, out var cached) && cached is ParsedBody existing)
return existing;
var body = new ParsedBody();
foreach (var pair in Request.Query)
{
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
}
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
{
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
}
}
else if ((Request.ContentLength ?? 0) > 0)
{
// Read the raw text rather than gating on Content-Type: the client's
// RawJsonForm does set application/json, but the JSON path is also
// the fallback for anything else that isn't a form.
using var reader = new StreamReader(
Request.Body, System.Text.Encoding.UTF8,
detectEncodingFromByteOrderMarks: true, leaveOpen: true);
var raw = await reader.ReadToEndAsync();
try
{
using var doc = JsonDocument.Parse(raw);
switch (doc.RootElement.ValueKind)
{
case JsonValueKind.Object:
foreach (var prop in doc.RootElement.EnumerateObject())
{
body.Fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? string.Empty
: prop.Value.GetRawText();
if (prop.Value.ValueKind == JsonValueKind.Array)
body.Repeated[prop.Name] = ElementToStrings(prop.Value);
}
break;
case JsonValueKind.Array:
body.RootArray = ElementToStrings(doc.RootElement);
break;
}
}
catch (JsonException)
{
// Empty or non-JSON body → query/form values only.
}
}
HttpContext.Items[BodyItemKey] = body;
return body;
}
}
@@ -18,10 +18,16 @@ namespace DorkNet.Server.Controllers.API.RoomConsumables;
/// LPFAFIGAIOE / HJOKFJKKEJM / IDBFKCFEAHM / PECGEJAAMHB / DPNLANKOHON):
///
/// desc = { RoomConsumableId, RoomId, Name, Description, ImageName,
/// PriceAndCurrency: { Price, CurrencyId } }
/// Price, PurchaseCurrencyId, ModifiedAt }
/// inventory = { RoomConsumableId, AccountId, Count, ConcurrencyCode,
/// ModifiedAt, Consumable: desc }
///
/// The desc price is FLAT on the way OUT even though it is NESTED on the way
/// IN. The response formatter FCIBLPCOODP's key map is exactly the eight keys
/// above — Price at index 5, PurchaseCurrencyId at 6, ModifiedAt at 7, and no
/// PriceAndCurrency key at all (FCIBLPCOODP.txt:670/686/710). See ToDescWire.
/// The inventory key map is likewise exact (CICEHLNDLDE.txt:447-562).
///
/// <c>Consumable</c> must never be null on an inventory row — the
/// client's RoomConsumablesManager dereferences it unconditionally while
/// processing the room-join inventory fetch and NREs otherwise (that NRE
@@ -311,7 +311,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
if (offerIds.Count > 0) query = query.Where(x => offerIds.Contains(x.o.PublicId));
if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId));
var rows = await query.Take(200).ToListAsync();
// The client reads an "Order" key (AALDCAANEMM.txt:550) but there is no
// column to persist it into yet, so ToOfferWire reports 0 for every pack
// and the client keeps the order it received them in — order by insertion
// id so that fallback is at least stable across fetches.
var rows = await query.OrderBy(x => x.o.Id).Take(200).ToListAsync();
// The response is GROUPED: List<{CurrencyId, PurchaseOffers}> (formatter
// LKBGCOELLIN.txt:215-258 reads only those two keys). A flat offer list
@@ -453,13 +457,13 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
fields[pair.Key] = Flatten(pair.Value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
fields[pair.Key] = Flatten(pair.Value);
return fields;
}
@@ -489,6 +493,21 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
return fields;
}
/// <summary>Collapses a multi-valued query/form key into the comma-joined form
/// <see cref="ReadGuidList"/>/<see cref="ReadLongList"/> already split on.
///
/// getPurchaseOffersBatch pushes its currency-id list through the generic batch
/// param helper (<c>0x181F2DCD0</c> with <c>"ids"</c>,
/// MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:228-230), which is the same helper
/// every other RecNet batch route uses to REPEAT the key once per id
/// (<c>?ids=a&amp;ids=b</c>). Taking only <c>FirstOrDefault()</c> silently
/// dropped every id after the first, so a multi-currency room fetched packs for
/// exactly one of its currencies.</summary>
private static string Flatten(Microsoft.Extensions.Primitives.StringValues values) =>
values.Count <= 1
? values.FirstOrDefault() ?? string.Empty
: string.Join(",", values.Where(v => !string.IsNullOrWhiteSpace(v)));
private async Task<string?> ReadJsonBodyAsync()
{
if ((Request.ContentLength ?? 0) <= 0) return null;
@@ -2409,8 +2409,31 @@ public class RoomsController(
[HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
/// <summary>Per-(player, room) persistence blob, base64. Lives in the
/// generic <see cref="PlayerSettingEntity"/> key/value table — the same
/// "no schema change needed" trick MatchPlayerController uses for its
/// matchmaking prefs (MatchPlayerController.cs:213-217). The Value column
/// is declared <c>MaxLength(1024)</c> but SQLite stores it as unconstrained
/// TEXT and EF Core does not run DataAnnotations validation on SaveChanges,
/// so oversized blobs round-trip intact.</summary>
private static string PlayerRoomDataKey(long roomId) => $"roomplayerdata:{roomId}";
/// <summary>Fallback blob handed to a player who has never saved anything
/// in this room. <c>CAE=</c> is a one-field protobuf the client parses as
/// "empty, version 1"; a truly empty string makes the 2023 reader throw.</summary>
private const string DefaultPlayerRoomData = "CAE=";
/// <summary>GET <c>rooms/{roomId}/playerdata/me</c> — the per-room player
/// blob (CircuitsV2 "player persistence"). Client contract is
/// <c>Task&lt;ILKMFMCOPPO&gt;</c> whose reader requires the key
/// <c>Data</c> (OPNPNPMKLGI.txt ISIL 034/060); the request is issued from
/// NLDBPDCNNCF (NLDBPDCNNCF_NestedType_IGENGEIMGMJ.txt:169), so it arrives
/// with the <c>roomserver/</c> prefix — the bare-only registration 404'd.
/// This used to return a hardcoded <c>CAE=</c> for everyone; it now returns
/// whatever <see cref="SavePlayerDataForMe"/> last stored.</summary>
[HttpGet("player_room_data/{roomId:long}")]
[HttpGet("rooms/{roomId:long}/playerdata/me")]
[HttpGet("roomserver/rooms/{roomId:long}/playerdata/me")]
[Authorize]
public async Task<IActionResult> PlayerDataForMe(long roomId)
{
@@ -2431,9 +2454,15 @@ public class RoomsController(
.Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt })
.FirstOrDefaultAsync();
var key = PlayerRoomDataKey(roomId);
var stored = await db.PlayerSettings.AsNoTracking()
.Where(s => s.PlayerId == pid.Value && s.Key == key)
.Select(s => s.Value)
.FirstOrDefaultAsync();
return Ok(new
{
Data = "CAE=",
Data = string.IsNullOrEmpty(stored) ? DefaultPlayerRoomData : stored,
RoomId = room?.Id ?? roomId,
PlayerId = pid.Value,
Favorite = favorited,
@@ -2453,6 +2482,71 @@ public class RoomsController(
});
}
public sealed class PlayerRoomDataRequest { public string? Data { get; set; } }
/// <summary>PUT <c>rooms/{roomId}/playerdata/me</c> — persist the caller's
/// per-room blob. Client method <c>NLDBPDCNNCF.LIHFBBCDAGO(long, string)</c>
/// ("save player room data") moves verb ordinal 3 = PUT into r8 and posts a
/// single form field <c>data</c>
/// (NLDBPDCNNCF.txt ISIL 045/061/065 around :2566-2586; form key at
/// NLDBPDCNNCF_NestedType_ANPKFGHOOAJ.txt ISIL 015). Its return type is the
/// non-generic <c>Task</c>, so no response body is parsed.
///
/// <para>Only the GET was registered before, so every save 405'd and per-room
/// player state never persisted.</para></summary>
[HttpPut("rooms/{roomId:long}/playerdata/me")]
[HttpPost("rooms/{roomId:long}/playerdata/me")]
[HttpPut("roomserver/rooms/{roomId:long}/playerdata/me")]
[HttpPost("roomserver/rooms/{roomId:long}/playerdata/me")]
[HttpPut("player_room_data/{roomId:long}")]
[Authorize]
public async Task<IActionResult> SavePlayerDataForMe(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] PlayerRoomDataRequest req)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
var data = req.Data ?? string.Empty;
var key = PlayerRoomDataKey(roomId);
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == pid.Value && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid.Value, Key = key, Value = data,
});
}
else
{
row.Value = data;
}
await db.SaveChangesAsync();
// The client ignores the body (non-generic Task); the echo is for
// curl/admin debugging only.
return Ok(new { Result = 0, RoomId = roomId, PlayerId = pid.Value, Data = data });
}
/// <summary>GET <c>rooms/requiring/{restriction}</c> — the restricted-rooms
/// list. The path segment is <c>mode.ToString().ToLower()</c>
/// (Matchmaking.txt ISIL 067-072 around :21263, concat onto the literal
/// <c>"rooms/requiring/"</c> at :21264).
///
/// <para><b>Element type is String, not Int64 — verified, do not "fix" this
/// to room ids.</b> The issuing method
/// <c>Matchmaking.POIMPHMONON(IOCOJFJEDCM)</c> (Matchmaking.txt:21030)
/// resolves its promise as <c>FGLDKEJLAKB&lt;List&lt;String&gt;&gt;</c>
/// (typeof load at Matchmaking.txt:21327, continuation delegate
/// <c>Action&lt;List&lt;String&gt;&gt;</c> at :21315) and hands the payload to
/// <c>&lt;RequestRoomRestrictions&gt;b__2(List&lt;String&gt; rooms)</c>
/// (Matchmaking_NestedType_PMNEOAMKLJL.txt:14), which forwards it to
/// <c>Matchmaking.IINEGHJJBAN(IEnumerable&lt;String&gt;, IOCOJFJEDCM)</c>
/// (:20758) where the strings become the KEYS of the restriction map
/// (<c>List&lt;IOCOJFJEDCM&gt;</c> per key, ISIL 069-079 at :20963-20974).
/// So a bare array of room NAMES is the correct wire shape. Failures log
/// "Failed to get restricted rooms list: " (Matchmaking.txt:21306).</para></summary>
[HttpGet("rooms/requiring/{restriction}")]
[HttpGet("roomserver/rooms/requiring/{restriction}")]
[Authorize]
@@ -2708,19 +2802,31 @@ public class RoomsController(
/// <para>We accept both POST and PUT because the watch's ISIL
/// doesn't expose the explicit HTTPMethod literal at this call
/// site — covering both is cheap and means we can't get bitten
/// by the wrong one.</para></summary>
/// by the wrong one.</para>
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.LHNDKOJNKBE</c>
/// ("change room name", NLDBPDCNNCF.txt:5069) moves verb ordinal 3 = PUT,
/// sends the lowercase form key <c>name</c>
/// (NLDBPDCNNCF_NestedType_LMADOAEHOPP.txt ISIL 015) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c> — the FULL room-details object, not a
/// status wrapper. It also carries the <c>roomserver/</c> prefix like every
/// other room mutation from that client
/// (docs/recroom-2023-room-save.md:83-93), so the bare-only routes 404'd.
/// The old signature mixed <c>[FromForm]</c> with a <c>[FromBody]</c>
/// parameter, which under <c>[ApiController]</c> makes the form PUT 415
/// before the handler runs — hence the shared FormOrJsonModelBinder.</para></summary>
public sealed class RenameBody { public string? Name { get; set; } }
[HttpPost("rooms/{roomId:long}/name")]
[HttpPut("rooms/{roomId:long}/name")]
[HttpPost("roomserver/rooms/{roomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/name")]
[Authorize]
public async Task<IActionResult> RenameRoom(
long roomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
@@ -2748,35 +2854,24 @@ public class RoomsController(
await db.SaveChangesAsync();
logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName);
// Return the full updated Room DTO. The watch's
// <c>PPENFJMFPNE</c> deserialiser inherits from
// <c>KLCOGEIGEBJ</c> (the standard Room shape) so we can
// forward to the same v3-details payload builder by re-reading.
// BuildRoomDetailsV3Async is too heavy here — just return the
// minimal mutated subset; the watch UI re-fetches details
// shortly after for the room-info screen.
return Ok(new
{
Result = 0,
RoomId = room.Id,
Name = room.Name,
Description = room.Description,
ImageName = room.ImageName,
CreatorPlayerId = room.CreatorPlayerId,
CreatorAccountId = room.CreatorPlayerId,
State = room.State,
Accessibility = room.Accessibility,
IsAGRoom = room.IsAGRoom,
IsRRO = false,
IsDormRoom = room.IsDormRoom,
});
// Full room details, not the old minimal {Result,RoomId,Name,…} object:
// the 2023 issuing method returns Task<FGCPNAACHIK> (NLDBPDCNNCF.txt:5069)
// and that reader needs Room/Scenes/CoOwners/Stats — the trimmed payload
// made a successful rename surface as a parse failure in the client.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>rooms/{roomId}/subrooms/{subRoomId}/name</c> —
/// rename a scene/sub-room within a multi-scene room. Watch path:
/// <c>EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName)</c>.
/// Updates the matching <see cref="RoomSceneEntity.Name"/> after
/// verifying ownership and intra-room uniqueness.</summary>
/// verifying ownership and intra-room uniqueness.
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.EJOHFBMFEGB</c>
/// ("change subroom name", NLDBPDCNNCF.txt:8694) is verb 3 = PUT with form
/// key <c>name</c> and returns <c>Task&lt;FGCPNAACHIK&gt;</c>. Same
/// [FromForm]+[FromBody] 415 hazard as the room rename above, so it binds
/// through the shared FormOrJsonModelBinder and echoes full details.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
@@ -2785,11 +2880,9 @@ public class RoomsController(
public async Task<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
@@ -2822,19 +2915,7 @@ public class RoomsController(
await db.SaveChangesAsync();
logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName);
return Ok(new
{
Result = 0,
RoomId = roomId,
RoomSceneId = scene.OrderIndex,
SubRoomId = scene.OrderIndex,
Name = scene.Name,
scene.DataBlobName,
RoomSceneLocationId = scene.RoomSceneLocationId,
scene.IsSandbox,
scene.MaxPlayers,
scene.CanMatchmakeInto,
});
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
@@ -2881,9 +2962,21 @@ public class RoomsController(
}
// ── Bare-path interactionby/me toggles ─────────────────────────────
//
// Verbs come straight from the 2023 client's four one-liner methods on
// NLDBPDCNNCF (each a non-generic Task, so no response body is parsed):
// "cheer room" :13467 r8 = 3 (PUT)
// "uncheer room" :13550 r8 = 4 (DELETE)
// "favorite room" :13633 r8 = 3 (PUT)
// "unfavorite room" :13716 r8 = 4 (DELETE)
// All four are issued from NLDBPDCNNCF, so they arrive with the
// roomserver/ prefix (docs/recroom-2023-room-save.md:83-93) — without the
// twins the cheer/favorite buttons 404'd and failed silently.
[HttpPost("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareCheer(long roomId)
{
@@ -2893,6 +2986,7 @@ public class RoomsController(
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareUncheer(long roomId)
{
@@ -2903,6 +2997,8 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPut("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareFavorite(long roomId)
{
@@ -2912,6 +3008,7 @@ public class RoomsController(
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareUnfavorite(long roomId)
{
@@ -3418,10 +3515,20 @@ public class RoomsController(
}
/// <summary>POST <c>rooms/{id}/subrooms</c> — append a new scene.
/// Owner-gated. OrderIndex = max+1, Name unique within room.</summary>
/// Owner-gated. OrderIndex = max+1, Name unique within room.
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.CMHBCOIBDME</c> ("add new
/// subroom", NLDBPDCNNCF.txt:9396) is verb 2 = POST with a single form key
/// <c>name</c> (NLDBPDCNNCF_NestedType_AIPHLHBILDN.txt ISIL 015) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. It needs the roomserver/ twin, a binder
/// that accepts the form body (<c>[FromBody]</c> 415s it), and full room
/// details in the response instead of the scene row.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task<IActionResult> AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req)
public async Task<IActionResult> AddSubRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3448,10 +3555,18 @@ public class RoomsController(
};
db.RoomScenes.Add(scene);
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>DELETE <c>rooms/{id}/subrooms/{sub}</c>. The 2023 caller
/// <c>NLDBPDCNNCF.HGNGMIMJFJL</c> ("delete subroom", NLDBPDCNNCF.txt:9827)
/// moves verb ordinal 4 = DELETE and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>; the only registration here used to be the
/// bare one (the roomserver/ twin at the top of the file is the GET), so
/// deleting a subroom 404'd, and the <c>{Result:0}</c> body was not the
/// details object the client re-parses.</summary>
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
@@ -3465,20 +3580,69 @@ public class RoomsController(
if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" });
db.RoomScenes.Remove(scene);
await db.SaveChangesAsync();
return Ok(new { Result = 0 });
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
public sealed class SubRoomIntRequest { public int? Value { get; set; } }
public sealed class SubRoomBoolRequest { public bool? Value { get; set; } }
// Subroom mutation DTOs. Property names mirror the LOWERCASE form keys the
// 2023 client actually sends (FormOrJsonModelBinder matches case-insensitively):
// maxPlayers NLDBPDCNNCF_NestedType_BLNOGFGHIIF.txt ISIL 020
// accessibility NLDBPDCNNCF_NestedType_GNCLJBNJEKP.txt ISIL 020
// name/accessibility/maxPlayers ..._JEEACJHABAF.txt ISIL 021/035/048
// newRoomId NLDBPDCNNCF_NestedType_COIOGADEBKJ.txt ISIL 020
// The older {Value:…} DTOs matched no client key at all, and being bound
// [FromBody] they 415'd the form body before the handler ran.
public sealed class SubRoomMaxPlayersRequest { public int? MaxPlayers { get; set; } }
// Accessibility is typed string, not int, on purpose — the two client call
// sites disagree about how they stringify it. The standalone
// "change subroom accessibility" closure boxes the value as
// typeof(JHAOFLCJNAL) (GNCLJBNJEKP.txt ISIL 015), and the form writer's
// object overload falls through to a virtual ToString() for anything that
// isn't String/DateTime (BNDIAONDFFF.txt ISIL 107-110) — i.e. an enum
// MEMBER NAME goes on the wire. The bulk "modify" closure boxes the very
// same value as typeof(System.Int32) (JEEACJHABAF.txt ISIL 031) and sends a
// number. ParseSubRoomAccessibility below accepts both spellings.
public sealed class SubRoomAccessibilityRequest { public string? Accessibility { get; set; } }
public sealed class SubRoomModifyRequest
{
public string? Name { get; set; }
public string? Accessibility { get; set; }
public int? MaxPlayers { get; set; }
public bool? IsSandbox { get; set; }
public bool? CanMatchmakeInto { get; set; }
public string? RoomSceneLocationId { get; set; }
}
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
public sealed class SubRoomMoveRequest
{
/// <summary>Target ROOM for the scene — the 2023 "move subroom" flow
/// re-parents a scene into another room. Nullable&lt;Int64&gt; on the
/// wire (NLDBPDCNNCF.MGGALMIAMBG, NLDBPDCNNCF.txt:9659).</summary>
public long? NewRoomId { get; set; }
/// <summary>Legacy in-room reorder used by the admin/import tooling.
/// Not something the 2023 client sends.</summary>
public int? NewIndex { get; set; }
}
/// <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; }
@@ -3501,29 +3665,94 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
public Task<IActionResult> SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); });
public Task<IActionResult> SubRoomMaxPlayers(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMaxPlayersRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.MaxPlayers is int v) s.MaxPlayers = Math.Max(1, v); });
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/accessibility</c>. Client:
/// <c>NLDBPDCNNCF.OAINKEAFPGE(long, long, JHAOFLCJNAL)</c>
/// (NLDBPDCNNCF.txt:9032, verb 3), form key <c>accessibility</c>, response
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. We only model the matchmaking bit
/// (<see cref="RoomSceneEntity.CanMatchmakeInto"/>), so Private collapses to
/// "not a matchmaking target" and everything else to "is one".</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
public Task<IActionResult> SubRoomAccessibility(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomAccessibilityRequest req) =>
MutateScene(roomId, subRoomId, s =>
{
if (ParseSubRoomAccessibility(req.Accessibility) is int a)
s.CanMatchmakeInto = a != AccessibilityPrivate;
});
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — 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).
///
/// <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>
[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 Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest? req) =>
MutateScene(roomId, subRoomId, s =>
{
if (req?.CanMatchmakeInto is bool canMatchmakeInto) s.CanMatchmakeInto = canMatchmakeInto;
if (req?.MaxPlayers is int maxPlayers) s.MaxPlayers = Math.Max(1, maxPlayers);
if (req?.IsSandbox is bool isSandbox) s.IsSandbox = isSandbox;
});
public async Task<IActionResult> SubRoomPermissions(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var 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);
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// JHAOFLCJNAL member values. The numeric leg is verified (the "modify"
// closure boxes the same value as System.Int32 — JEEACJHABAF.txt ISIL 031);
// the NAMES are inferred from the room-accessibility enum in the nearest
// dump with real names (RecRoom-2023.06.21 dump.cs:1293997-1294004
// MKDFFJGGKDL { Private = 0, Public = 1, Unlisted = 2 }), which is the enum
// Enum.ToString() would spell out on the standalone accessibility call.
private const int AccessibilityPrivate = 0;
private static int? ParseSubRoomAccessibility(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var value = raw.Trim();
if (int.TryParse(value, out var numeric)) return numeric;
if (value.StartsWith("priv", StringComparison.OrdinalIgnoreCase)) return 0;
if (value.StartsWith("pub", StringComparison.OrdinalIgnoreCase)) return 1;
if (value.StartsWith("unlist", StringComparison.OrdinalIgnoreCase)) return 2;
return null;
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
@@ -3558,7 +3787,9 @@ public class RoomsController(
scene.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = scene.DataModifiedAt;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
// "restore subroom save" returns Task<FGCPNAACHIK> (NLDBPDCNNCF.txt:10476),
// so the scene row alone made a successful restore read as a failure.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task<PublishSubRoomSaveRequest> ReadPublishSubRoomSaveRequestAsync()
@@ -3640,34 +3871,68 @@ public class RoomsController(
};
db.RoomScenes.Add(clone);
await db.SaveChangesAsync();
return Ok(SceneWire(clone));
// Task<FGCPNAACHIK> ("clone subroom", NLDBPDCNNCF.txt:9554) — the scene
// row is not what the client re-parses.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// Subroom settings save POSTs here on the roomserver host ("failed to
// modify room" = the bare-only routes 404'd). Same roomserver/ prefix as
// the sibling subroom mutations.
//
// Body is form-urlencoded name/accessibility/maxPlayers ("modify room",
// NLDBPDCNNCF.txt:10846 verb 3; keys at JEEACJHABAF.txt ISIL 021/035/048).
// [FromBody] rejected that with 415 before the handler ran, and the DTO had
// no 'accessibility' member at all, so the bulk subroom-settings save was a
// guaranteed failure.
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
public Task<IActionResult> ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) =>
public Task<IActionResult> ModifySubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomModifyRequest req) =>
MutateScene(roomId, subRoomId, s =>
{
if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim();
if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp);
if (req.IsSandbox is bool sb) s.IsSandbox = sb;
if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi;
if (ParseSubRoomAccessibility(req.Accessibility) is int a)
s.CanMatchmakeInto = a != AccessibilityPrivate;
if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId;
});
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c> — reorder a
/// scene. Body NewIndex is the target OrderIndex; the in-between
/// rows shift to fill the gap.</summary>
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c>.
///
/// <para><b>The 2023 semantics are "move to another ROOM", not "reorder".</b>
/// <c>NLDBPDCNNCF.MGGALMIAMBG(long roomId, long subRoomId,
/// Nullable&lt;Int64&gt; newRoomId)</c> ("move subroom",
/// NLDBPDCNNCF.txt:9659, verb 2 = POST) sends the single form key
/// <c>newRoomId</c> (COIOGADEBKJ.txt ISIL 013-020, boxed as
/// <c>Nullable&lt;Int64&gt;</c>) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. The old handler bound
/// <c>[FromBody] {NewIndex}</c> — a key the client never sends, on a body
/// binding that 415s form posts — and reordered scenes in place instead.</para>
///
/// <para>The caller must own BOTH rooms. The scene is re-parented to the end
/// of the target room's scene list and the source room's remaining scenes
/// are re-packed so <c>OrderIndex</c> stays a dense 0..n-1 sequence — the
/// wire <c>RoomSceneId</c> and matchmaking <c>SubRoomId</c> are that index.
/// Scene 0 is the room's entry scene and cannot be moved out.
/// <c>newIndex</c> (no client sends it) keeps the old in-room reorder for
/// the import tooling.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
public async Task<IActionResult> MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req)
public async Task<IActionResult> MoveSubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3677,19 +3942,69 @@ public class RoomsController(
.Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync();
var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId);
if (moving is null) return NotFound();
var newIndex = Math.Clamp(req.NewIndex ?? 0, 0, scenes.Count - 1);
scenes.Remove(moving);
scenes.Insert(newIndex, moving);
// Recompute OrderIndex on the entire row set. The unique
// (RoomId, OrderIndex) index would clash if we wrote rows in
// place — flush a sentinel offset first, then settle to
// final indices.
// `newRoomId` is Nullable<Int64> on the wire and the form writer
// stringifies it with ToString(), which yields "" for the empty case —
// so an absent target arrives as a blank field, not a missing one.
// Treat "no target and no index" as a no-op; the old code fell through
// to `NewIndex ?? 0` and silently reordered the scene to the front.
if (req.NewRoomId is not long targetRoomId || targetRoomId == roomId)
{
if (req.NewIndex is not int requestedIndex)
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
var newIndex = Math.Clamp(requestedIndex, 0, scenes.Count - 1);
scenes.Remove(moving);
scenes.Insert(newIndex, moving);
await RepackSceneOrderAsync(scenes);
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// Cross-room move — the path the 2023 client actually takes.
var target = await db.Rooms.FirstOrDefaultAsync(r => r.Id == targetRoomId);
if (target is null) return NotFound(new { error = "target_room_not_found", targetRoomId });
if (target.CreatorPlayerId != pid) return Forbid();
if (moving.OrderIndex == 0)
return BadRequest(new { error = "cannot_move_entry_scene" });
// Names are unique per room (RoomScenes index on (RoomId, Name)).
var movedName = moving.Name;
var suffix = 1;
while (await db.RoomScenes.AnyAsync(s => s.RoomId == targetRoomId && s.Name == movedName))
{
suffix += 1;
movedName = $"{moving.Name} {suffix}";
}
var targetOrder = (await db.RoomScenes.Where(s => s.RoomId == targetRoomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
moving.RoomId = targetRoomId;
moving.OrderIndex = targetOrder;
moving.Name = movedName;
moving.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = moving.DataModifiedAt;
target.UpdatedAt = moving.DataModifiedAt;
await db.SaveChangesAsync();
// Re-pack the source room so its remaining scenes keep dense indices
// (same sentinel dance as the in-room reorder above).
await RepackSceneOrderAsync(scenes.Where(s => s.Id != moving.Id).ToList());
logger.LogInformation("[subroom-move] scene={Scene} room={From}→{To} by={By}",
moving.Id, roomId, targetRoomId, pid);
return Ok(await BuildRoomServerDetailsWithRolesAsync(targetRoomId));
}
/// <summary>Rewrite <see cref="RoomSceneEntity.OrderIndex"/> to 0..n-1 in
/// list order. The unique (RoomId, OrderIndex) index would clash if rows
/// were written in place, so flush a sentinel offset first, then settle.</summary>
private async Task RepackSceneOrderAsync(List<RoomSceneEntity> scenes)
{
if (scenes.Count == 0) return;
const int sentinelOffset = 100_000;
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset;
await db.SaveChangesAsync();
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i;
await db.SaveChangesAsync();
return Ok(SceneWire(moving));
}
/// <summary>POST <c>roomserver/rooms/{id}/subrooms/{sub}/data</c> —
@@ -3762,6 +4077,11 @@ public class RoomsController(
return null;
}
/// <summary>Shared owner-gate + mutate + save for the subroom setting
/// endpoints. Responds with the FULL room details: every 2023 caller that
/// lands here (maxplayers / accessibility / modify) is typed
/// <c>Task&lt;FGCPNAACHIK&gt;</c>, so the old scene-row payload made a
/// successful mutation surface as a client-side parse failure.</summary>
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{
var pid = this.RequireCurrentPlayerId();
@@ -3773,7 +4093,7 @@ public class RoomsController(
mutator(scene);
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task<RoomSceneEntity?> GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId)
@@ -3798,25 +4118,21 @@ public class RoomsController(
return scene;
}
private static object SceneWire(RoomSceneEntity s) => new
{
Result = 0,
RoomId = s.RoomId,
RoomSceneId = s.OrderIndex,
SubRoomId = s.OrderIndex,
Name = s.Name,
s.DataBlobName,
RoomSceneLocationId = s.RoomSceneLocationId,
s.IsSandbox,
s.MaxPlayers,
s.CanMatchmakeInto,
};
// SceneWire (the old {Result,RoomId,RoomSceneId,…} scene-row payload) is
// gone: every subroom mutation the 2023 client issues is typed
// Task<FGCPNAACHIK> and re-parses the full room details, so all of these
// handlers now go through BuildRoomServerDetailsWithRolesAsync.
// ── Bare-path roles ────────────────────────────────────────────────
/// <summary>GET <c>rooms/{id}/roles</c> — account role DTOs consumed by
/// 2023's EFHPLDPNGIM deserializer.</summary>
/// 2023's EFHPLDPNGIM deserializer
/// (<c>NLDBPDCNNCF.LAJDCNIMBAB</c> → <c>Task&lt;List&lt;EFHPLDPNGIM&gt;&gt;</c>,
/// NLDBPDCNNCF.txt:2111, verb 0 = GET). Like every other NLDBPDCNNCF call it
/// arrives with the <c>roomserver/</c> prefix, so the bare-only registration
/// 404'd the permissions screen's list fetch.</summary>
[HttpGet("rooms/{roomId:long}/roles")]
[HttpGet("roomserver/rooms/{roomId:long}/roles")]
public async Task<IActionResult> RolesList(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3939,7 +4255,12 @@ public class RoomsController(
return new StudioBundleInfo(saveId, target, version, name);
}
/// <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>
[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);
@@ -3954,10 +4275,17 @@ public class RoomsController(
public sealed class GrantRoleRequest { public int? Role { get; set; } }
/// <summary>POST <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).</summary>
/// <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>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
@@ -3999,9 +4327,13 @@ public class RoomsController(
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
/// as grant but Accepted=false; the target's accept-invite flow
/// flips the flag (separate endpoint, not yet exposed). For now
/// invited rows surface in RoomDetails.InvitedCoOwners etc.</summary>
/// invited rows surface in RoomDetails.InvitedCoOwners etc.
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
@@ -721,43 +721,6 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
return null;
}
private async Task<JsonElement?> ReadJsonElementAsync(params string[] stringKeys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var dict = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var key in form.Keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) dict[key] = value;
}
if (dict.Count == 0) return null;
return JsonSerializer.SerializeToElement(dict);
}
if ((Request.ContentLength ?? 0) > 0)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
return doc.RootElement.Clone();
}
catch (JsonException)
{
return null;
}
}
foreach (var key in stringKeys)
{
var value = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return JsonSerializer.SerializeToElement(value);
}
return null;
}
// ── Multi-field payload reader ──────────────────────────────────────
//
// The single-key Read*Async helpers above each parse Request.Body, and a
@@ -1184,6 +1147,18 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
});
}
/// <summary>PUT <c>rooms/{id}/promo_external</c> — "add room promotional
/// content" (verb byte 3 = PUT at NLDBPDCNNCF.txt:8181). Both the add and
/// the remove closures send exactly two form keys: <c>type</c> — a BOXED
/// System.Int32 (NLDBPDCNNCF/OJAKIAHKALI:017 boxes typeof(System.Int32),
/// key at :022) — and <c>reference</c>, a string (:031).
///
/// The old code stored the raw form dictionary, so Type landed on the
/// wire as the STRING "1"; the client's promo-external reader
/// (EPPPACFECMH.txt:191/:210 registers Type + Reference, and :233 reads
/// Type through the numeric accessor FAHHHNKECAB while :254 reads
/// Reference through the string accessor BKKEIINKNCL) rejects a string
/// there. Build the two members explicitly and typed.</summary>
[HttpPost("rooms/{roomId:long}/promo_external")]
[HttpPut("rooms/{roomId:long}/promo_external")]
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
@@ -1191,70 +1166,186 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
{
var payload = await ReadJsonElementAsync("url", "Url", "value", "Value");
if (payload is null) return BadRequest(new { error = "missing_promo_external" });
var payload = await ReadPayloadAsync();
var reference = Value(payload, "reference", "Reference", "url", "Url", "value", "Value");
if (string.IsNullOrWhiteSpace(reference))
return BadRequest(new { error = "missing_promo_external" });
var type = IntValue(payload, "type", "Type") ?? 0;
return await ApplyAndReturn(roomId, r =>
{
var items = ReadJsonElementList(r.PromoExternalContentJson);
var raw = payload.Value.GetRawText();
if (!items.Any(i => string.Equals(i.GetRawText(), raw, StringComparison.Ordinal)))
items.Add(payload.Value);
var items = ReadJsonElementList(r.PromoExternalContentJson)
.Where(i => !PromoExternalMatches(i, type, reference))
.ToList();
items.Add(JsonSerializer.SerializeToElement(new
{
Type = type,
Reference = reference,
}));
r.PromoExternalContentJson = SerializeLimited(items);
});
}
/// <summary>DELETE <c>rooms/{id}/promo_external</c> — "remove room
/// promotional content" (verb byte 4 = DELETE at NLDBPDCNNCF.txt:8343).
/// Identity is the (<c>type</c>, <c>reference</c>) PAIR
/// (NLDBPDCNNCF/LLFPMJMCLBP:022 and :031), not an id/url — the old
/// id/url/value lookup always missed, so every remove 400'd with
/// missing_promo_external and promo links could not be deleted.</summary>
[HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
{
var match = await ReadStringValueAsync("id", "Id", "url", "Url", "value", "Value");
if (string.IsNullOrWhiteSpace(match)) return BadRequest(new { error = "missing_promo_external" });
var payload = await ReadPayloadAsync();
var reference = Value(payload, "reference", "Reference", "id", "Id", "url", "Url", "value", "Value");
if (string.IsNullOrWhiteSpace(reference))
return BadRequest(new { error = "missing_promo_external" });
// type is absent on the 2020.12 watch's remove — treat "no type" as
// "match on reference alone".
var type = IntValue(payload, "type", "Type");
return await ApplyAndReturn(roomId, r =>
{
var items = ReadJsonElementList(r.PromoExternalContentJson)
.Where(i => !PromoExternalMatches(i, match))
.Where(i => !PromoExternalMatches(i, type, reference))
.ToList();
r.PromoExternalContentJson = SerializeLimited(items);
});
}
private static bool PromoExternalMatches(JsonElement item, string match)
/// <summary>A stored promo-external entry matches when its Reference
/// does and (when the caller supplied one) its Type agrees. Legacy rows
/// written before the typed shape landed may be bare strings or carry
/// Url/Id instead of Reference, so those spellings are still probed.
/// </summary>
private static bool PromoExternalMatches(JsonElement item, int? type, string reference)
{
if (string.Equals(item.GetRawText(), match, StringComparison.OrdinalIgnoreCase)) return true;
if (item.ValueKind == JsonValueKind.String)
return string.Equals(item.GetString(), match, StringComparison.OrdinalIgnoreCase);
return string.Equals(item.GetString(), reference, StringComparison.OrdinalIgnoreCase);
if (item.ValueKind != JsonValueKind.Object) return false;
foreach (var key in new[] { "Id", "id", "Url", "url", "Value", "value" })
var referenceMatches = false;
foreach (var key in new[] { "Reference", "reference", "Url", "url", "Id", "id", "Value", "value" })
{
if (item.TryGetProperty(key, out var prop) &&
prop.ValueKind == JsonValueKind.String &&
string.Equals(prop.GetString(), match, StringComparison.OrdinalIgnoreCase))
return true;
string.Equals(prop.GetString(), reference, StringComparison.OrdinalIgnoreCase))
{
referenceMatches = true;
break;
}
}
return false;
if (!referenceMatches) return false;
if (type is not int wanted) return true;
foreach (var key in new[] { "Type", "type" })
{
if (!item.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n == wanted;
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out n)) return n == wanted;
}
// Untyped legacy row — reference alone is enough.
return true;
}
/// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c>
/// — fields not persisted in the 2020 RoomEntity. Owner-gated
/// acknowledgement so the watch's settings panel doesn't surface
/// a "save failed" error; populated when the columns land.</summary>
/// <summary><c>rooms/{id}/comments</c> (form key <c>disable</c>, Boolean —
/// NLDBPDCNNCF/EKLEDLOPLJB:015/:020) and
/// <c>rooms/{id}/voice_chat_encryption</c> (form key
/// <c>encryptVoiceChat</c>, Boolean — NLDBPDCNNCF/NDKDDENPHJP:015/:020).
/// Both are PUT (verb byte 3 at NLDBPDCNNCF.txt:6879 and :7033) and both
/// are issued under the roomserver/ prefix by the 2023 in-room settings
/// panel — the missing twins made them 404 there.
///
/// The VALUES still aren't persisted: RoomEntity has no
/// DisableRoomComments / EncryptVoiceChat column, so RoomService.cs:1122
/// and RoomsController.cs:2130 hardcode both to false on the wire.
/// Adding those columns (plus a migration and the two detail builders)
/// is outside this file. Until then this stays an owner-gated
/// acknowledgement that answers with the full details object the call
/// site's reader [0x1884C1948] expects, so the panel doesn't report a
/// save failure on top of the toggle not sticking.</summary>
[HttpPost("rooms/{roomId:long}/comments")]
[HttpPut("rooms/{roomId:long}/comments")]
[HttpPost("roomserver/rooms/{roomId:long}/comments")]
[HttpPut("roomserver/rooms/{roomId:long}/comments")]
[HttpPost("rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("rooms/{roomId:long}/voice_chat_encryption")]
[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")]
public async Task<IActionResult> BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* no-op: not modelled yet */ });
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
/// <summary><c>rooms/{id}/modify</c> — bare-path equivalent
/// of <c>api/rooms/v2/modify</c>. Reuses <see cref="Modify"/> by
/// forwarding the body with RoomId pre-populated from the URL.</summary>
/// <summary>PUT <c>rooms/{id}/modify</c> — the room-settings panel's bulk
/// save (verb byte 3 = PUT at NLDBPDCNNCF.txt:10829, "modify room"), and
/// the 2023 client issues it under the roomserver/ prefix too.
///
/// The body is x-www-form-urlencoded with ELEVEN camelCase keys, emitted
/// in this order by NLDBPDCNNCF/MMMNLOBFFKO: <c>name</c> (string, :039),
/// <c>description</c> (string, :049), <c>accessibility</c> (Int32, boxed
/// at :059 / key :063), then eight Booleans — <c>supportsScreens</c>
/// (:076), <c>supportsWalkVR</c> (:089), <c>supportsTeleportVR</c>
/// (:102), <c>supportsJuniors</c> (:115), <c>cloningAllowed</c> (:128),
/// <c>disableMicAutoMute</c> (:141), <c>disableRoomComments</c> (:154)
/// and <c>encryptVoiceChat</c> (:167).
///
/// The old <c>[FromBody] ModifyRoomRequest</c> forwarder 415'd on that
/// form body before the handler ran, was missing four of the fields, and
/// answered <c>{Result, Room}</c> — but the call site attaches the full
/// details reader [0x1884C1948] (:10826), the same one clone uses, so
/// the response must be BuildRoomServerDetails.
///
/// disableRoomComments / encryptVoiceChat are deliberately NOT read here:
/// RoomEntity has no column for either, so there is nowhere to put them
/// (see <see cref="BareAck"/> above).</summary>
[HttpPost("rooms/{roomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/modify")]
public async Task<IActionResult> BareModify(long roomId, [FromBody] ModifyRoomRequest req)
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareModify(long roomId)
{
req.RoomId = roomId;
return await Modify(req);
var payload = await ReadPayloadAsync();
var name = Value(payload, "name", "Name");
// RawValue for description: clearing it sends the key with "".
var description = RawValue(payload, "description", "Description");
var accessibility = IntValue(payload, "accessibility", "Accessibility");
var imageName = Value(payload, "imageName", "ImageName");
var screens = BoolValue(payload, "supportsScreens", "SupportsScreens");
var walkVR = BoolValue(payload, "supportsWalkVR", "SupportsWalkVR");
var teleportVR = BoolValue(payload, "supportsTeleportVR", "SupportsTeleportVR");
var juniors = BoolValue(payload, "supportsJuniors", "AllowsJuniors");
var cloning = BoolValue(payload, "cloningAllowed", "CloningAllowed");
var autoMute = BoolValue(payload, "disableMicAutoMute", "DisableMicAutoMute");
var mobile = BoolValue(payload, "supportsMobile", "SupportsMobile");
var vrLow = BoolValue(payload, "supportsVRLow", "SupportsVRLow");
var room = await RequireOwnedRoomAsync(roomId);
if (room is null) return NotFound();
if (!string.IsNullOrWhiteSpace(name)) room.Name = name.Trim();
if (description is not null) room.Description = description;
if (imageName is not null) room.ImageName = imageName;
if (accessibility is int a) room.Accessibility = Math.Clamp(a, 0, 2);
if (screens is bool s) room.SupportsScreens = s;
if (walkVR is bool w) room.SupportsWalkVR = w;
if (teleportVR is bool t) room.SupportsTeleportVR = t;
if (juniors is bool j) room.AllowsJuniors = j;
if (cloning is bool c) room.CloningAllowed = c;
if (autoMute is bool m) room.DisableMicAutoMute = m;
if (mobile is bool mo) room.SupportsMobile = mo;
if (vrLow is bool vl) room.SupportsVRLow = vl;
room.UpdatedAt = DateTime.UtcNow;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
// Likely a unique-name collision.
return Conflict(new { Result = 2, Message = "name taken" });
}
return Ok(await BuildRoomDetailsAsync(room));
}
/// <summary>POST <c>rooms/{id}/clone</c> — bare-path alias of
@@ -1463,24 +1554,89 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
}
// ── Bare-path bans aliases ─────────────────────────────────────────
public sealed class BareBanRequest { public long PlayerId { get; set; } public int BanType { get; set; } public DateTime? Until { get; set; } public string? Reason { get; set; } }
//
// Every one of these needs the roomserver/ twin: the 2023 in-room
// banned-players panel issues them under that prefix
// (NLDBPDCNNCF.txt:12901 GET, :13044 POST "ban players", :13191 POST
// "rooms/{0}/bans/import", :13353 DELETE "rooms/{0}/bans/{1}"), and a
// bare-only registration 404s there.
[HttpGet("rooms/{roomId:long}/bans")]
[HttpGet("roomserver/rooms/{roomId:long}/bans")]
public Task<IActionResult> BareBansList(long roomId) => RoomBansList(roomId);
/// <summary>POST <c>rooms/{id}/bans</c> — "ban players" (verb byte 2 =
/// POST at NLDBPDCNNCF.txt:13060). The client sends
/// x-www-form-urlencoded with ONE <c>banMask</c> (boxed Int32) plus a
/// REPEATED <c>id</c> key — one per account being banned
/// (NLDBPDCNNCF/DMMILEJMMCC:024 and :034; the `id` binder is the
/// list-valued overload, fed from the
/// <c>IReadOnlyList&lt;Int32&gt;</c> parameter of the issuing method at
/// NLDBPDCNNCF.txt:12920). The old <c>[FromBody] BareBanRequest</c>
/// rejected the form body with 415 before the handler ran, and named
/// PlayerId/BanType — keys the client never sends — so in-room bans
/// never persisted. No response reader is attached at the call site
/// (plain BDBHCEGKGDC dispatch, :13065), so the ack shape is free.
/// </summary>
[HttpPost("rooms/{roomId:long}/bans")]
public Task<IActionResult> BareBan(long roomId, [FromBody] BareBanRequest req) =>
BanFromRoom(new BanFromRoomRequest
[HttpPost("roomserver/rooms/{roomId:long}/bans")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareBan(long roomId)
{
var room = await RequireOwnedRoomAsync(roomId);
if (room is null) return NotFound();
var payload = await ReadPayloadAsync();
// banMask IS the BanType enum on the wire. Default 1 (Permanent):
// the in-room panel's only ban action is a permanent ban.
var banType = IntValue(payload, "banMask", "banType", "type", "BanType") ?? 1;
var reason = RawValue(payload, "reason", "Reason") ?? string.Empty;
DateTime? until = DateTime.TryParse(Value(payload, "until", "Until"), out var parsedUntil)
? (DateTime?)parsedUntil.ToUniversalTime()
: null;
// "id" is the 2023 key; the others are 2020.12 / admin spellings. The
// payload map is case-insensitive, so PascalCase variants fall out.
var ids = Values(payload, "id", "playerId", "accountId")
.Select(v => long.TryParse(v, out var n) ? n : 0L)
.Where(n => n > 0)
.Distinct()
.ToList();
if (ids.Count == 0) return BadRequest(new { error = "missing_player" });
var existing = await db.RoomBans
.Where(b => b.RoomId == roomId && ids.Contains(b.BannedPlayerId))
.ToListAsync();
foreach (var id in ids)
{
RoomId = roomId,
PlayerId = req.PlayerId,
BanType = req.BanType,
Until = req.Until,
Reason = req.Reason,
});
// Re-banning an already-banned player updates the row rather than
// stacking duplicates — the panel re-sends the whole selection.
var row = existing.FirstOrDefault(b => b.BannedPlayerId == id);
if (row is null)
{
db.RoomBans.Add(new RoomBanEntity
{
RoomId = roomId,
BannedPlayerId = id,
BannedByPlayerId = Me,
BanType = banType,
Until = until,
Reason = reason.Length > 512 ? reason[..512] : reason,
});
}
else
{
row.BanType = banType;
row.Until = until;
if (reason.Length > 0) row.Reason = reason.Length > 512 ? reason[..512] : reason;
}
}
await db.SaveChangesAsync();
return Ok(new { Result = 0, Banned = ids.Count });
}
[HttpDelete("rooms/{roomId:long}/bans/{playerId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/bans/{playerId:long}")]
public async Task<IActionResult> BareUnban(long roomId, long playerId)
{
var room = await RequireOwnedRoomAsync(roomId);
@@ -1494,11 +1650,59 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
return Ok(new { Removed = rows.Count });
}
/// <summary>POST <c>rooms/{id}/bans/import</c> — "import ban list"
/// (verb byte 2 = POST at NLDBPDCNNCF.txt:13207). The client names a
/// SOURCE ROOM whose bans should be copied in — a single Int64 form key
/// <c>sourceRoomId</c> (NLDBPDCNNCF/KGDNOLMFENE:015 boxes System.Int64,
/// :020 is the key) — NOT an explicit ban list. The old
/// <c>[FromBody] ImportRoomBansRequest</c> therefore 415'd on the form
/// body AND modelled the wrong contract. The JSON ban-list shape stays
/// available on the legacy <c>api/rooms/v1/importroombans</c>.</summary>
[HttpPost("rooms/{roomId:long}/bans/import")]
public Task<IActionResult> BareBansImport(long roomId, [FromBody] ImportRoomBansRequest req)
[HttpPost("roomserver/rooms/{roomId:long}/bans/import")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareBansImport(long roomId)
{
req.RoomId = roomId;
return ImportRoomBans(req);
var target = await RequireOwnedRoomAsync(roomId);
if (target is null) return NotFound();
var payload = await ReadPayloadAsync();
// Deliberately NOT aliased to "roomId" — that name means the TARGET
// room elsewhere in this surface and would silently self-import.
var sourceRoomId = LongValue(payload, "sourceRoomId", "SourceRoomId") ?? 0;
if (sourceRoomId <= 0) return BadRequest(new { error = "missing_source_room" });
if (sourceRoomId == roomId) return Ok(new { Imported = 0 });
// Owner-gate BOTH rooms — importing bulk-reads the source room's ban
// list, which is owner-only information (see RoomBansList).
var source = await RequireOwnedRoomAsync(sourceRoomId);
if (source is null) return Forbid();
var sourceBans = await db.RoomBans
.Where(b => b.RoomId == sourceRoomId)
.ToListAsync();
var seen = new HashSet<long>(await db.RoomBans
.Where(b => b.RoomId == roomId)
.Select(b => b.BannedPlayerId)
.ToListAsync());
var imported = 0;
foreach (var b in sourceBans)
{
if (!seen.Add(b.BannedPlayerId)) continue;
db.RoomBans.Add(new RoomBanEntity
{
RoomId = roomId,
BannedPlayerId = b.BannedPlayerId,
BannedByPlayerId = Me,
BanType = b.BanType,
Until = b.Until,
Reason = b.Reason,
});
imported++;
}
if (imported > 0) await db.SaveChangesAsync();
return Ok(new { Imported = imported });
}
private async Task<IActionResult> ApplyAndReturn(long roomId, Action<RoomEntity> mutator)
@@ -1509,6 +1713,33 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
room.UpdatedAt = DateTime.UtcNow;
try { await db.SaveChangesAsync(); }
catch (DbUpdateException) { return Conflict(new { Result = 2 }); }
return Ok(Services.RoomService.ToWireRoom(room));
return Ok(await BuildRoomDetailsAsync(room));
}
/// <summary>The response body EVERY bare-path room mutation must return.
///
/// The 2023 roomserver client dispatches all of them through the same
/// typed helper (<c>0x1835E50A0</c>) with the same response-reader token
/// <c>[0x1884C1948]</c> — see NLDBPDCNNCF.txt:5366 (description),
/// :5691 (tags), :6006 (accessibility), :6169 (restrictions),
/// :6335 (cloning), :6710 (automute), :6876 (comments), :7195 (min_level),
/// :7360 (allow_new_users), :7859 (promo_images), :8178 (promo_external),
/// :8516 (loadscreen), :10826 (modify). That is the SAME token the clone
/// call site uses (:4815), which the repo already established is the full
/// FGCPNAACHIK details object built by BuildRoomServerDetails (see
/// <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>
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
{
var scenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
return RoomsController.BuildRoomServerDetails(room, scenes, roles: roles);
}
}
@@ -40,6 +40,16 @@ public class StorefrontsBuyController(
public int StorefrontType { get; set; }
public int PurchasableItemId { get; set; }
public int CurrencyType { get; set; } = 2;
/// <summary>The price the tile displayed, sent as a stale-price guard.
/// The field was missing here, so buyTier/buyElite had nothing to
/// validate against and fell back to a flat cost. Verified on the
/// 2023-03-21 build: <c>Nullable&lt;Int64&gt;</c> at +32 in
/// <c>RecNet.Runtime/DCFKEFHJAGC_NestedType_RequestPurchaseItemDTO.txt:123-137</c>,
/// wire key <c>"RequestedPrice"</c> in the generated JSON contract
/// <c>RecNet.Runtime/FPLGJGDJIJG.txt:534</c>.</summary>
public long? RequestedPrice { get; set; }
public long? CouponConsumablePlayerMappingId { get; set; }
public GiftDto? Gift { get; set; }
}
@@ -185,36 +195,75 @@ public class StorefrontsBuyController(
isGift ? null : gift.Id));
}
/// <summary>POST <c>api/storefronts/v2/buyTier</c> — season-pass "skip a
/// tier" button. The tier is an ordinary StoreItems row on the
/// <c>season:{n}</c> storefront (see StorefrontsController.Season), so its
/// real price is charged rather than a flat 1 token, and the purchase is
/// recorded as a <c>seasonTier:*</c> progress row so the season page can
/// count how many tiers the player bought.</summary>
[HttpPost("api/storefronts/v2/buyTier")]
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
{
// Season-pass tier: no per-item table, so we just deduct a
// small fixed cost (1 token) per tier and ack. A full
// season-pass system would have a SeasonProgressEntity.
if (req is null) return BadRequest("missing body");
var pid = Me;
const int TierCost = (int)StoreService.StoreItemPrice;
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
// Tiers that aren't seeded as StoreItems still cost the catalogue
// default so the button isn't free on a bare install.
var cost = Math.Max(0, item?.Price ?? StoreService.StoreItemPrice);
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
if (balance < TierCost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -TierCost,
$"buyTier:{req.PurchasableItemId}");
// Stale-price guard: the client sends the price its tile showed.
if (req.RequestedPrice is long requested && requested != cost)
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
if (balance < cost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = cost == 0
? balance
: await level.GrantCurrencyAsync(pid, req.CurrencyType, -cost,
$"buyTier:{req.PurchasableItemId}");
await RecordSeasonProgressAsync(pid,
$"seasonTier:{req.StorefrontType}:{req.PurchasableItemId}");
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
/// <summary>POST <c>api/storefronts/v2/buyElite</c> — season "elite
/// upgrade" (the paid premium track). There is no real-money flow on a
/// private server, so it is priced in tokens off the same StoreItems row
/// the season storefront advertised. The flag is persisted as a
/// <c>seasonElite:*</c> progress row.</summary>
[HttpPost("api/storefronts/v2/buyElite")]
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
{
// Elite-tier purchase (paid premium track). Without real
// money flow on a private server we just ack.
if (req is null) return BadRequest("missing body");
var pid = Me;
var eliteKey = $"seasonElite:{req.StorefrontType}";
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
// Already upgraded — ack without charging again so a replayed tap
// can't drain the wallet.
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == eliteKey))
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
var cost = Math.Max(0, item?.Price ?? StoreService.StoreItemPrice);
if (req.RequestedPrice is long requested && requested != cost)
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
if (balance < cost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = cost == 0
? balance
: await level.GrantCurrencyAsync(pid, req.CurrencyType, -cost,
$"buyElite:{req.PurchasableItemId}");
await RecordSeasonProgressAsync(pid, eliteKey);
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
/// <summary>GET <c>api/storefronts/v1/buyRoomKey</c> — purchase a
/// persisted room key. The 2020.12 client sends roomKeyId and the
/// price it displayed so we reject stale-price purchases.</summary>
[HttpGet("api/storefronts/v1/buyRoomKey")]
[HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
public async Task<IActionResult> BuyRoomKey(
[FromQuery] long roomKeyId,
[FromQuery] int requestedPrice)
@@ -252,93 +301,369 @@ public class StorefrontsBuyController(
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
}
public sealed class PurchaseRoomKeyWithCurrencyRequest
{
public long RoomKeyId { get; set; }
public long RequestedPrice { get; set; }
public Guid RequestedPurchaseCurrencyId { get; set; }
}
/// <summary>POST <c>api/storefronts/v1/PurchaseRoomKeyWithCurrency</c> —
/// buying a room key with a ROOM currency instead of tokens.
///
/// This was only registered as a <c>[HttpGet]</c> alias of BuyRoomKey, so
/// every attempt 405'd. The 2023-03-21 client issues it with verb 2 (POST —
/// <c>RecNet.Runtime/DCFKEFHJAGC.txt:7426</c>) and three form fields,
/// <c>RoomKeyId</c> (Int64, :7439), <c>RequestedPrice</c> (Int64, :7451) and
/// <c>RequestedPurchaseCurrencyId</c> (Guid, :7464); the DTO signature is
/// <c>INBGDNMFJBP(Int64, Int64, Guid)</c> at :7206.
///
/// The response is <c>RoomKeyPurchaseWithCurrencyResponseDTO</c>, whose
/// generated contract has exactly two members — <c>Balance</c> and
/// <c>RoomKeyResponse</c> (<c>RecNet.Runtime/EDMKBBPGJAB.txt:203/222</c>) —
/// where Balance is the ROOM-currency balance row
/// {AccountId, CurrencyId, Balance, ModifiedAt}
/// (<c>RecNet.Runtime/DPNLANKOHON.txt:331-414</c>), NOT the token
/// BalanceUpdateResponse that the GET alias returned.</summary>
[HttpPost("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
public async Task<IActionResult> PurchaseRoomKeyWithCurrency(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
PurchaseRoomKeyWithCurrencyRequest req)
{
var pid = Me;
var currency = await db.RoomCurrencies.FirstOrDefaultAsync(
c => c.PublicId == req.RequestedPurchaseCurrencyId && !c.IsDeleted);
var key = await db.RoomKeys.FirstOrDefaultAsync(
k => k.Id == req.RoomKeyId && !k.IsDeleted);
RoomCurrencyBalanceEntity? row = null;
if (currency is not null)
{
var currencyRowId = currency.Id;
row = await db.RoomCurrencyBalances.FirstOrDefaultAsync(
b => b.PlayerId == pid && b.RoomCurrencyId == currencyRowId);
}
if (currency is null)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.InvalidParameters, key, pid, req.RequestedPurchaseCurrencyId, null));
if (key is null)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.DoesNotExist, null, pid, currency.PublicId, row));
if (key.CreatorPlayerId == pid)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.PlayerAlreadyOwns, key, pid, currency.PublicId, row));
var roomKeyRowId = key.Id;
if (await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyRowId && p.PlayerId == pid))
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.PlayerAlreadyOwns, key, pid, currency.PublicId, row));
if (req.RequestedPrice != key.Price)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.InvalidParameters, key, pid, currency.PublicId, row));
var balance = row?.Balance ?? 0;
if (balance < key.Price)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.PurchaseFailed, key, pid, currency.PublicId, row));
if (row is null)
{
row = new RoomCurrencyBalanceEntity
{
PlayerId = pid,
RoomCurrencyId = currency.Id,
Balance = 0,
};
db.RoomCurrencyBalances.Add(row);
}
row.Balance = Math.Max(0, row.Balance - key.Price);
row.UpdatedAt = DateTime.UtcNow;
db.RoomKeyPurchases.Add(new RoomKeyPurchaseEntity
{
RoomKeyId = key.Id,
PlayerId = pid,
PaidPrice = key.Price,
});
await db.SaveChangesAsync();
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.Success, key, pid, currency.PublicId, row));
}
/// <summary>GET <c>api/storefronts/v2/buyInvention</c> — MakerPen store
/// purchase of a paid invention. Verb is 0/GET
/// (<c>RecNet.Runtime/DCFKEFHJAGC.txt:6772</c>) with query fields
/// <c>inventionId</c> (Int64, :6785) and <c>requestedPrice</c> (Int32,
/// :6797) — the latter was being ignored.
///
/// The response type is <c>InventionPurchaseResponseDTO</c>, whose contract
/// is {<c>InventionResponse</c>, <c>BalanceUpdateResponse</c>}
/// (<c>RecNet.Runtime/MAHHAAEGEEP.txt:215/242</c>). The handler used to
/// return the bare token-balance wrapper, so <c>InventionResponse</c> was
/// absent and the client got Status/Invention/InventionVersion = null —
/// it could never confirm the purchase or spawn what it just bought.
/// InventionResponse itself is {Status, Invention, InventionVersion}
/// (<c>RecNet.Runtime/GMBGBPNMGBA.txt:255/274/290</c>) — the same triple
/// InventionsController already returns from addversion.</summary>
[HttpGet("api/storefronts/v2/buyInvention")]
[HttpPost("api/storefronts/v2/buyInvention")]
[HttpGet("api/storefronts/v2/buyInvention")]
public async Task<IActionResult> BuyInvention([FromQuery] long inventionId, [FromQuery] int currencyType = 2)
public async Task<IActionResult> BuyInvention(
[FromQuery] long inventionId,
[FromQuery] long? requestedPrice = null,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var inv = await db.Inventions.FirstOrDefaultAsync(i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
if (inv is null) return NotFound();
var price = Math.Max(0, inv.Price);
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var inv = await db.Inventions.FirstOrDefaultAsync(
i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
if (inv is null)
return Ok(InventionPurchaseResponse(
InventionStatusDoesNotExist, null, null, balance, currencyType));
var version = await db.InventionVersions
.Where(v => v.InventionId == inv.Id)
.OrderByDescending(v => v.VersionNumber)
.FirstOrDefaultAsync();
var price = Math.Max(0, inv.Price);
// Stale-price guard — the MakerPen tile sends the price it rendered.
if (requestedPrice is long asked && asked != price)
return Ok(InventionPurchaseResponse(
InventionStatusInvalidParameters, inv, version, balance, currencyType));
var key = $"invention_purchase:{inv.Id}";
var alreadyOwns = inv.CreatorPlayerId == pid
|| await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key);
if (alreadyOwns)
{
// Deliberately Success rather than PlayerAlreadyOwns (=19): the
// MakerPen re-issues this call on every spawn of a paid creation,
// and a non-zero status blocks the spawn.
return Ok(InventionPurchaseResponse(
InventionStatusSuccess, inv, version, balance, currencyType));
}
if (balance < price)
return Ok(InventionPurchaseResponse(
InventionStatusPurchaseFailed, inv, version, balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, $"buyInvention:{inv.Id}");
var key = $"invention_purchase:{inv.Id}";
if (!await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key))
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
if (price > 0 && inv.CreatorPlayerId != pid)
await level.GrantCurrencyAsync(inv.CreatorPlayerId, currencyType, price,
$"sellInvention:{inv.Id}");
return Ok(InventionPurchaseResponse(
InventionStatusSuccess, inv, version, newBalance, currencyType));
}
/// <summary>Body of <c>buyForFreeGiftButton</c>. Unlike its siblings this
/// route does NOT use form fields: the client serialises a
/// <c>RequestPurchaseForFreeGiftButtonDTO</c> and posts it as the whole
/// body (<c>RecNet.Runtime/DCFKEFHJAGC.txt:5910-5924</c>, verb 2 at :5912).
/// Field offsets +16/+24/+40/+44/+48 in
/// <c>DCFKEFHJAGC_NestedType_RequestPurchaseForFreeGiftButtonDTO.txt</c>,
/// wire keys PurchasableItemId / CouponConsumablePlayerMappingId / Count /
/// CreatorPlayerId / RequestedPrice in <c>RecNet.Runtime/IAPMDDFENDD.txt:395-502</c>.
/// </summary>
public sealed class FreeGiftButtonRequest
{
public int PurchasableItemId { get; set; }
public long? CouponConsumablePlayerMappingId { get; set; }
public int Count { get; set; } = 1;
public int CreatorPlayerId { get; set; }
public long? RequestedPrice { get; set; }
}
/// <summary>POST <c>api/storefronts/v1/buyForFreeGiftButton</c> — the
/// in-room "free gift button" gadget handing an item to whoever pressed it.
///
/// The handler bound <c>[FromQuery] currencyType/price</c> while the client
/// sends everything in the JSON body, so the requested item id never
/// arrived and a hardcoded 25-token "FreeGiftButton" package was inserted
/// instead — the gadget paid out the wrong reward on every press. Now the
/// body is read and the requested StoreItems row is granted directly.
///
/// The response is the plain <c>BalanceResponseDTO</c>
/// (<c>DCFKEFHJAGC.txt:6008/6055</c>) whose contract is exactly
/// {Balance, CurrencyType, Platform} — <c>RecNet.Runtime/PECGEJAAMHB.txt:255/274/298</c>.
/// There is no gift-package member, so nothing can be handed back through a
/// gift box here; the item has to land in the inventory server-side.</summary>
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
public async Task<IActionResult> BuyForFreeGiftButton(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
FreeGiftButtonRequest req,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
if (item is null) return Ok(BalanceResponse(balance, currencyType));
// RequestedPrice is what the gadget quoted, and it is the total for the
// press — the gadget is normally free, so the item's catalogue price is
// deliberately NOT charged here. CouponConsumablePlayerMappingId is
// carried by the DTO but there is no coupon-mapping table to redeem it
// against yet, so it is accepted and ignored.
var price = Math.Max(0, req.RequestedPrice ?? 0);
if (balance < price) return Ok(BalanceResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price,
$"freeGiftButton:{item.Slug}");
await GrantPurchasedItemAsync(pid, item, Math.Clamp(req.Count, 1, 50));
return Ok(BalanceResponse(newBalance, currencyType));
}
/// <summary>Form body of <c>buyProgressionEventXpBoost</c>, verified at
/// <c>RecNet.Runtime/DCFKEFHJAGC.txt:12431</c> (progressionEventId, Int64),
/// :12444 (purchasableXpBoostId, Guid), :12456 (requestedPrice, Int32) and
/// :12468 (expectedXp, Int32).</summary>
public sealed class ProgressionEventXpBoostRequest
{
public long ProgressionEventId { get; set; }
public Guid PurchasableXpBoostId { get; set; }
public int RequestedPrice { get; set; }
public int ExpectedXp { get; set; }
}
/// <summary>POST <c>api/storefronts/v1/buyProgressionEventXpBoost</c>.
///
/// Two defects: the handler bound <c>[FromQuery] currencyType/price</c> so
/// none of the four form fields arrived (nothing charged, no XP granted),
/// and it reused the shared BalanceUpdateResponse whose
/// <c>BalanceUpdates[].Data</c> is a LIST. This route's response is
/// <c>BalanceUpdateResponseDTO`1&lt;PurchasedProgressionEventXpBoostDTO&gt;</c>
/// (signature at <c>DCFKEFHJAGC.txt:12118</c>) and that family's inner Data
/// is a SINGLE object — <c>get_Data</c> returns <c>DataTypeDTO</c>, not
/// <c>List&lt;DataTypeDTO&gt;</c>, in
/// <c>DCFKEFHJAGC_NestedType_BalanceUpdateResponseDTO`1_NestedType_DEJIAFEKFCK.txt:83</c>
/// (contrast the Purchase* variant at
/// <c>..._PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83</c>,
/// which IS a List). An array there makes the reader throw.
/// The payload object is {Xp:Int32} — <c>RecNet.Runtime/NNEJHCKLOHL.txt:139</c>.</summary>
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
public async Task<IActionResult> BuyProgressionEventXpBoost(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
ProgressionEventXpBoostRequest req,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var price = Math.Max(0, req.RequestedPrice);
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(XpBoostResponse(balance, currencyType, 0));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price,
$"progressionEventXpBoost:{req.ProgressionEventId}");
// The client tells us how much XP the boost it just paid for is worth;
// clamped so a spoofed field can't mint arbitrary levels.
var xp = Math.Clamp(req.ExpectedXp, 0, 100_000);
if (xp > 0)
await level.AwardXpAsync(pid, xp, $"progressionEventXpBoost:{req.ProgressionEventId}");
// Upsert: (PlayerId, Key) is unique, and a boost can legitimately be
// bought more than once — a blind Add threw
// "UNIQUE constraint failed" and turned the second purchase into a 500
// AFTER the currency had already been taken.
var progressKey = $"progressionEvent:{req.ProgressionEventId}:xpBoost:{req.PurchasableXpBoostId:N}";
var progress = await db.ObjectiveProgress
.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == progressKey);
if (progress is null)
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = key,
Key = progressKey,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
return Ok(BalanceUpdateResponse(newBalance, currencyType, null, null));
}
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
public async Task<IActionResult> BuyForFreeGiftButton([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, "freeGiftButton");
db.GiftPackages.Add(new GiftPackageEntity
else
{
RecipientPlayerId = pid,
CurrencyType = 2,
Currency = 25,
Level = 1,
PackageVariant = "FreeGiftButton",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
});
progress.IsCompleted = true;
progress.ClearedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(BalanceUpdateResponse(newBalance, currencyType));
return Ok(XpBoostResponse(newBalance, currencyType, xp));
}
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
public async Task<IActionResult> BuyProgressionEventXpBoost([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
/// <summary>Form body of <c>buyPurchaseReminder</c>, verified at
/// <c>RecNet.Runtime/DCFKEFHJAGC.txt:10203</c> (purchaseReminderId, Int32)
/// and :10215 (requestedPrice, Int64).</summary>
public sealed class PurchaseReminderRequest
{
public int PurchaseReminderId { get; set; }
public long RequestedPrice { get; set; }
}
/// <summary>POST <c>api/storefronts/v1/buyPurchaseReminder</c> — buying the
/// item a "you were looking at this" reminder popup advertises.
///
/// Was a stub returning <c>{Success:true}</c>; the client deserialises
/// <c>JNPGKDJJPPM</c> = {Balance:Int64, CurrencyType, BalanceType, Data:
/// List&lt;GiftPackage&gt;} (signature at <c>DCFKEFHJAGC.txt:9928</c>, keys at
/// <c>RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386</c>), so every field
/// defaulted and the wallet read 0. Note this DTO really does spell its
/// third member <c>BalanceType</c>, unlike the BalanceUpdateResponse family
/// which renames the same property to <c>Platform</c>.
///
/// There is no reminder catalogue on a private server — the offer feed
/// (<c>reminder/currentTokenBundles/v2</c>) is genuinely empty — so the
/// reminder id is resolved as the StoreItems row it points at. Unknown ids
/// charge nothing and return an empty Data list.</summary>
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
public async Task<IActionResult> BuyPurchaseReminder(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
PurchaseReminderRequest req,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var newBalance = price == 0
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchaseReminderId && s.IsActive);
if (item is null)
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
// Stale-price guard; 0 means the popup didn't quote a price.
if (req.RequestedPrice > 0 && req.RequestedPrice != item.Price)
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
if (balance < item.Price)
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
var newBalance = item.Price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, "progressionEventXpBoost");
: await level.GrantCurrencyAsync(pid, currencyType, -item.Price,
$"buyPurchaseReminder:{item.Slug}");
var giftId = await GrantWithGiftBoxAsync(pid, item, currencyType);
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = $"progressionEvent:xpBoost:{Guid.NewGuid():N}",
Key = $"purchaseReminder:{req.PurchaseReminderId}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(BalanceUpdateResponse(newBalance, currencyType));
}
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
public async Task<IActionResult> BuyPurchaseReminder()
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = Me,
Key = $"purchaseReminder:{Guid.NewGuid():N}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
return Ok(PurchaseReminderResponse(newBalance, currencyType, item.Slug, giftId));
}
// ── Helpers ──────────────────────────────────────────────────────────
@@ -438,6 +763,53 @@ public class StorefrontsBuyController(
// GiftPackageEntity.Id. The previous code used
// DateTime.UtcNow.Ticks as a fake id.
var giftId = giftPackageId ?? DateTime.UtcNow.Ticks;
return new
{
// BalanceResponseDTO required trio.
Balance = balance,
CurrencyType = currencyType,
// The 2023-03-21 build renames the base DTO's third member to
// "Platform" on the whole BalanceUpdateResponse family — its
// generated contract table lists exactly BalanceUpdates / Balance /
// CurrencyType / Platform and has NO "BalanceType"
// (RecNet.Runtime/KILGEFOENFN.txt:319/346/362/386, identical in
// BBEFAELEANL/IBAPHBCOBHL/ALGKCAKCFAJ/AIEJFLGKIGN). Emitting only
// BalanceType left Platform defaulted to 0 on the client. Both are
// shipped: the 2020.12 watch reads BalanceType and unknown members
// are ignored on both readers.
Platform = 0,
BalanceType = 0,
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
// List<BalanceUpdateDTO<GiftPackage>>. Note the Data member here is
// a LIST (…_PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83)
// — the plain BalanceUpdateResponseDTO family uses a single object.
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = grantedSlug is null
? Array.Empty<object>()
: new object[] { GiftPackageWire(giftId, currencyType, grantedSlug) },
},
},
};
}
/// <summary>One <c>FFFIMAGLKEG/FHMABOHAEED</c> gift package.
///
/// REQUIRED via Util.GetKey — KeyNotFoundException on miss. Verified keys at
/// NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946 and re-confirmed against
/// the 2023-03-21 contract table RecNet.Runtime/FKANCKLCCDI.txt:1051-1374:
/// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
/// ConsumableItemDesc, EquipmentPrefabName, EquipmentModificationGuid,
/// Platform, PlatformsToSpawnOn, BalanceType, CurrencyType, Currency, Xp,
/// GiftRarity, GiftContext. Missing any → "Failed to download gifts:
/// Malformed Response" (and on a purchase path, the buy times out because
/// the response never deserializes).</summary>
private static object GiftPackageWire(long giftId, int currencyType, string? grantedSlug)
{
// Pull the GUID off the slug for wardrobe-* purchases so the
// watch's GiftPackage receives the AvatarItem GUID and can
// render the unlock animation. dorknet-* slugs leave the desc
@@ -460,17 +832,8 @@ public class StorefrontsBuyController(
}
}
var giftPackage = new
return new
{
// REQUIRED via Util.GetKey — KeyNotFoundException on miss.
// Verified keys at NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946:
// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
// ConsumableItemDesc, EquipmentPrefabName,
// EquipmentModificationGuid, Platform, PlatformsToSpawnOn,
// BalanceType, CurrencyType, Currency, Xp, GiftRarity,
// GiftContext. Missing any → "Failed to download gifts:
// Malformed Response" (and on a purchase path, the buy
// times out because the response never deserializes).
Id = giftId,
Platform = 0,
PlatformsToSpawnOn = -1,
@@ -493,28 +856,142 @@ public class StorefrontsBuyController(
Consumed = false,
ErrorMessage = string.Empty,
};
}
/// <summary>Bare <c>BalanceResponseDTO</c> — {Balance, CurrencyType,
/// Platform} and nothing else, per the generated contract at
/// <c>RecNet.Runtime/PECGEJAAMHB.txt:255/274/298</c>. BalanceType is kept
/// alongside Platform for the 2020.12 watch, which reads that spelling.
/// </summary>
private static object BalanceResponse(long balance, int currencyType) => new
{
Balance = balance,
CurrencyType = currencyType,
Platform = 0,
BalanceType = 0,
};
/// <summary><c>BalanceUpdateResponseDTO`1&lt;PurchasedProgressionEventXpBoostDTO&gt;</c>.
/// Data is a SINGLE object here, not a list — see the BuyProgressionEventXpBoost
/// remarks for the ISIL citation.</summary>
private static object XpBoostResponse(long balance, int currencyType, int xp) => new
{
Balance = balance,
CurrencyType = currencyType,
Platform = 0,
BalanceType = 0,
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = new { Xp = xp },
},
},
};
/// <summary><c>JNPGKDJJPPM</c> — the buyPurchaseReminder response.
/// {Balance, CurrencyType, BalanceType, Data:List&lt;GiftPackage&gt;}
/// (<c>RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386</c>). "Platform" is
/// emitted as an inert alias because this one DTO is the odd one out that
/// really does use "BalanceType".</summary>
private static object PurchaseReminderResponse(long balance, int currencyType,
string? grantedSlug, long? giftPackageId) => new
{
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
Platform = 0,
Data = grantedSlug is null
? Array.Empty<object>()
: new[] { GiftPackageWire(giftPackageId ?? DateTime.UtcNow.Ticks, currencyType, grantedSlug) },
};
// InventionResponse.Status values, from the name-preserved enum
// ENOCILAAKGN in the 2023.06.21 dump
// (Recnet-old/dist/RecRoom-2023.06.21-steam/il2cppdump/dump.cs:1268793-1268840).
private const int InventionStatusSuccess = 0;
private const int InventionStatusInvalidParameters = 1;
private const int InventionStatusDoesNotExist = 7;
private const int InventionStatusPurchaseFailed = 30;
private static object InventionPurchaseResponse(int status,
InventionEntity? inv, InventionVersionEntity? version,
long balance, int currencyType)
{
object? inventionWire = inv is null ? null : InventionWire(inv);
object? versionWire = version is null ? null : InventionVersionWire(version);
return new
{
// BalanceResponseDTO required trio.
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
// List<BalanceUpdateDTO<GiftPackage>>.
BalanceUpdates = new[]
InventionResponse = new
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = grantedSlug is null
? Array.Empty<object>()
: new object[] { giftPackage },
},
Status = status,
Invention = inventionWire,
InventionVersion = versionWire,
},
// InventionPurchaseResponseDTO/EPFIAEMPKJK is
// BalanceUpdateResponseDTO`1<Invention> — it extends
// BalanceResponseDTO (BalanceUpdates at +32) and its inner Data is
// an Invention, not a balance delta, so the list is left empty and
// the client takes the balance off the outer trio.
BalanceUpdateResponse = new
{
Balance = balance,
CurrencyType = currencyType,
Platform = 0,
BalanceType = 0,
BalanceUpdates = Array.Empty<object>(),
},
};
}
/// <summary>Local copy of InventionsController's invention wire shape —
/// that builder is private to its own controller, and the buyInvention
/// response has to carry the same object. Keep the two in sync; the field
/// list is documented there (RecNet.Runtime/IOEPPCKGBFL.txt:1447-1939 is the
/// 2023-03-21 key table).</summary>
private static object InventionWire(InventionEntity i) => new
{
InventionId = i.Id,
ReplicationId = string.IsNullOrEmpty(i.ReplicationId)
? Guid.Empty.ToString("D") : i.ReplicationId,
CreatorPlayerId = (int)i.CreatorPlayerId,
i.Name,
i.Description,
i.ImageName,
i.CurrentVersionNumber,
i.IsPublished,
AllowTrial = true,
ModifiedAt = i.UpdatedAt,
i.CreatedAt,
i.FirstPublishedAt,
i.CreationRoomId,
i.NumPlayersHaveUsedInRoom,
NumDownloads = i.SpawnCount,
i.CheerCount,
i.CreatorPermission,
i.GeneralPermission,
IsAGInvention = i.IsAgInvention,
i.Price,
HideFromPlayer = false,
};
/// <summary>Local copy of InventionsController's version wire shape.
/// The 2023-03-21 reader also looks for ChipsCost, CloudVariablesCost and
/// BlobHash (RecNet.Runtime/OLPHKLCPFEF.txt:786/810/858); none has a column
/// on InventionVersionEntity, so they are omitted rather than emitted as
/// permanent zeros/nulls.</summary>
private static object InventionVersionWire(InventionVersionEntity v) => new
{
v.InventionId,
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
? Guid.Empty.ToString("D") : v.ReplicationId,
v.VersionNumber,
v.InstantiationCost,
v.LightsCost,
v.BlobName,
};
private static object RoomKeyPurchaseResponse(RoomKeyStatus status, RoomKeyEntity? key, long balance) => new
{
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
@@ -522,8 +999,111 @@ public class StorefrontsBuyController(
{
Balance = balance,
CurrencyType = 2,
Platform = 0,
BalanceType = 0,
BalanceUpdates = Array.Empty<object>(),
},
};
/// <summary><c>RoomKeyPurchaseWithCurrencyResponseDTO</c> — {Balance,
/// RoomKeyResponse} where Balance is the ROOM-currency balance row
/// (<c>RecNet.Runtime/EDMKBBPGJAB.txt:203/222</c>, member shape at
/// <c>DPNLANKOHON.txt:331-414</c>), matching what
/// RoomCurrenciesController already emits for the same row.</summary>
private static object RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus status, RoomKeyEntity? key, long playerId, Guid currencyId,
RoomCurrencyBalanceEntity? row) => new
{
Balance = new
{
AccountId = (int)playerId,
CurrencyId = currencyId,
Balance = row?.Balance ?? 0L,
ModifiedAt = row is null
? DateTime.UtcNow
: DateTime.SpecifyKind(row.UpdatedAt, DateTimeKind.Utc),
},
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
};
/// <summary>Grants a purchased StoreItems row. Wardrobe unlocks are a set,
/// so a multi-count press still yields one entry; everything else stacks in
/// PlayerInventory.</summary>
private async Task GrantPurchasedItemAsync(long pid, StoreItemEntity item, int count = 1)
{
if (StoreService.TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc))
{
await GrantWardrobeAsync(pid, StoreService.InventoryAvatarItemDesc(avatarItemDesc));
return;
}
for (var n = 0; n < Math.Max(1, count); n++)
await GrantInventoryAsync(pid, item.Slug);
}
/// <summary>Grants <paramref name="item"/> and inserts the matching
/// gift-box row, mirroring BuyItem: the watch pops a gift box after the
/// purchase and POSTs the package id to /api/avatar/v2/gifts/consume/, so
/// the id in the response has to be a real GiftPackageEntity.</summary>
private async Task<long> GrantWithGiftBoxAsync(long pid, StoreItemEntity item, int currencyType)
{
await GrantPurchasedItemAsync(pid, item);
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc);
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
StoreService.TryGetEquipmentPayload(item.Slug, out var equipmentPrefabName, out var equipmentModificationGuid);
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
inventoryAvatarDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
// Same 4-part "{guid},,," desc BuyItem ships — the bare guid makes the
// client's AvatarItem parser miss on unlock.
var giftWireDesc = string.IsNullOrEmpty(avatarItemDesc) ? inventoryAvatarDesc : avatarItemDesc;
var gift = new GiftPackageEntity
{
RecipientPlayerId = pid,
AvatarItemType = string.IsNullOrEmpty(giftWireDesc) ? null : avatarItemType,
AvatarItemDescOrHairDyeDesc = giftWireDesc,
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName,
EquipmentModificationGuid = equipmentModificationGuid,
CurrencyType = currencyType,
Currency = 0,
Xp = 0,
Level = 1,
GiftContext = 0,
GiftRarity = 0,
Message = string.Empty,
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
Consumed = false,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
return gift.Id;
}
/// <summary>Idempotently records a season-pass purchase. There is no
/// SeasonProgressEntity, so tier/elite state lives on ObjectiveProgress
/// under <c>seasonTier:{storefrontType}:{purchasableItemId}</c> and
/// <c>seasonElite:{storefrontType}</c> — the same table the season
/// objectives feed already uses.</summary>
private async Task RecordSeasonProgressAsync(long pid, string key)
{
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key)) return;
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
}
@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
@@ -24,20 +26,24 @@ namespace DorkNet.Server.Controllers.Clubs;
/// POST /announcements/club/{clubId}/{announcementId}/read
///
/// Wire types (deserialiser JSON keys, per ISIL):
/// Club (<c>PLILLKHMNDA</c>): ClubId, Name, Description,
/// MainImageName, State, CreatorAccountId, Category,
/// Visibility, Joinability, AllowJuniors, MemberCount,
/// IsRRO, ClubhouseRoomId, ClubType
/// Announcement (<c>NFEMLMAFFIP</c>): AnnouncementId,
/// Club (2020 <c>PLILLKHMNDA</c> / 2023 <c>FOIJDINBPFG</c>):
/// ClubId, Name, Description, MainImageName, State,
/// CreatorAccountId, Category, Visibility, Joinability,
/// AllowJuniors, MemberCount, MinLevel, IsRRO,
/// ClubChatEnabled, ClubhouseRoomId, ClubType
/// Announcement (<c>NFEMLMAFFIP</c> / <c>JDPPAFLFNBD</c>): AnnouncementId,
/// CreatorAccountId, ClubId, Title, Body, ImageName,
/// CreatedAt, Meta
/// UnreadResponse (<c>HPACLJHLHBG</c>) — wraps per-club rows:
/// { clubId, LastReadAnnouncementId, announcements: [Announcement] }
/// UnreadResponse (<c>EIKPFIDCKNE</c> / <c>NCHLBFPHFJE</c>) — a LIST of
/// per-club rows { clubId, LastAnnouncementId,
/// LastReadAnnouncementId, announcements: [Announcement] }
/// ClubFeed (<c>HPACLJHLHBG</c> / <c>FIAKMDGGIHH</c>) — ONE such row,
/// not a list; see <c>AnnouncementsForClub</c>.
/// CategoryTags plain <c>List&lt;String&gt;</c> (per
/// <c>JDJGIBLMFKK.GetPrimaryTags</c> callback shape).
/// </summary>
[ApiController]
public class ClubsController(ClubService clubs) : ControllerBase
public class ClubsController(ClubService clubs, DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
@@ -154,7 +160,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
{
var pid = Me;
var rows = await clubs.UnreadDirectAsync(pid);
return Ok(GroupByClub(rows));
return Ok(GroupByClub(rows, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId))));
}
[HttpGet("/announcements/v2/subscription/mine/unread")]
@@ -163,7 +169,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
{
var pid = Me;
var rows = await clubs.UnreadSubscriptionAsync(pid);
return Ok(GroupByClub(rows));
return Ok(GroupByClub(rows, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId))));
}
/// <summary>GET <c>/subscription/mine/member</c> — list of clubs the
@@ -204,6 +210,17 @@ public class ClubsController(ClubService clubs) : ControllerBase
return Ok(playerSubs + clubSubs);
}
/// <summary>GET <c>/subscription/details/{accountId}</c> — the creator's
/// subscription card. Both clients read the same three keys: the 2020 watch
/// via <c>LNIKPLKOBDK</c> (exact-match lowercase <c>accountId</c>,
/// <c>clubId</c>, <c>subscriberCount</c>) and 2023 via <c>DDFNNBDLBKF</c>
/// (tri-cased AccountId/ClubId/SubscriberCount, <c>DDFNNBDLBKF.txt:279-346</c>)
/// — camelCase satisfies both, so the casing here is load-bearing for 2020
/// and must not be Pascal-ised.
///
/// <c>clubId</c> used to be missing entirely. The 2023 caller is literally
/// <c>GetCreatorClubIdForSubscription</c>, so the field defaulted to 0 and
/// profile→creator-club navigation went nowhere.</summary>
[HttpGet("/subscription/details/{accountId:long}")]
public async Task<IActionResult> SubscriptionDetails(long accountId)
{
@@ -212,9 +229,14 @@ public class ClubsController(ClubService clubs) : ControllerBase
var subscribedCount = await clubs.PlayerSubscribedCountAsync(accountId);
var isSubscribed = viewerId is long viewer
&& await clubs.IsSubscribedToPlayerAsync(viewer, accountId);
// A creator's "creator club" is the club they own; oldest created wins
// when an account somehow owns several.
var created = await clubs.CreatedByAsync(accountId);
var clubId = created.Count > 0 ? created.Min(c => c.Id) : 0L;
return Ok(new
{
accountId,
clubId,
subscriberCount,
subscribedCount,
isSubscribed,
@@ -297,16 +319,19 @@ public class ClubsController(ClubService clubs) : ControllerBase
return await BuildDetailsResponseAsync(clubId);
}
/// <summary>GET <c>/club/{id}/permissions/{role}</c> — bare int of
/// the permission bitmask for a role on this club. Used by the
/// watch's role-permissions screen. Returns 0 for unknown roles so
/// the screen renders an empty checklist instead of erroring.</summary>
/// <summary>GET <c>/club/{id}/permissions/{role}</c> — the permission
/// record for one role on this club. This used to answer a bare int; both
/// clients model it as an OBJECT (2023 <c>MMOCDPPONNG</c>, 2020
/// <c>JHEEFBMODPG</c> — identical layout: long ClubId, membership-type
/// enum, six bools), so see <see cref="PermissionsForRole"/>. Neither
/// client actually issues the GET (both only PUT this template), but the
/// shapes now agree.</summary>
[HttpGet("/club/{clubId:long}/permissions/{role:int}")]
public async Task<IActionResult> ClubPermissions(long clubId, int role)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
return Ok(PermissionsForRole(role));
return Ok(PermissionsForRole(clubId, role));
}
// ── Account / created lists ──────────────────────────────────────
@@ -369,6 +394,17 @@ public class ClubsController(ClubService clubs) : ControllerBase
public int? Joinability { get; set; }
public bool? AllowJuniors { get; set; }
public long? ClubhouseRoomId { get; set; }
/// <summary>Repeated <c>customTags</c> form field sent by
/// <c>club/{0}/modifydetails</c> alongside visibility / joinability /
/// allowJuniors (<c>IKMMOCKDKAF_NestedType_MAFIJOPDGHK.txt:139-185</c>).
/// Null means "not supplied"; an empty list clears the club's tags.
/// Note the form path can only ever deliver null or a non-empty list —
/// <c>FormOrJsonModelBinder.Convert</c> collapses an all-blank repeated
/// field to null — so a client that clears every tag over
/// form-urlencoded leaves the existing set in place. Only the JSON path
/// can send an explicit <c>[]</c>.</summary>
public List<string>? CustomTags { get; set; }
}
/// <summary>POST <c>/club/{id}/modify</c> + <c>/modifydetails</c> —
@@ -397,6 +433,9 @@ public class ClubsController(ClubService clubs) : ControllerBase
if (req.ClubhouseRoomId is long ch) c.ClubhouseRoomId = ch;
});
if (updated is null) return NotFound();
// Tags live in the club↔tag junction, not on the club row, so they
// are written after ModifyAsync has already vetted the caller.
if (req.CustomTags is not null) await ReplaceCustomTagsAsync(updated.Id, req.CustomTags);
return await BuildDetailsResponseAsync(updated.Id);
}
catch (UnauthorizedAccessException) { return Forbid(); }
@@ -422,12 +461,17 @@ public class ClubsController(ClubService clubs) : ControllerBase
catch (UnauthorizedAccessException) { return Forbid(); }
}
/// <summary>POST <c>/club/{id}/additionalimage/{slot}</c> — the
/// watch's gallery-image slot setter. The 2020 ClubEntity doesn't
/// have additional image slots persisted yet, so we just return
/// the current details unchanged (the gallery renders blank).
/// Hooked up so the request doesn't 404 — populated once a future
/// schema patch lands.</summary>
/// <summary>POST/PUT <c>/club/{id}/additionalimage/{slot}</c> — the club
/// gallery's per-slot image setter (<c>IKMMOCKDKAF.txt:16214</c>).
///
/// The request now binds (the client sends form-urlencoded
/// <c>imageName</c>, which <c>[FromBody]</c> used to reject with 415), but
/// the slot itself STILL is not persisted: there is no additional-image
/// table, and the envelope's <c>AdditionalImages</c> is the client's
/// <c>HIKCHBLAMLP</c> — {ImageName, Slot} — with nowhere to come from. So
/// the call is accepted and permission-checked, the details envelope comes
/// back refreshed, and the gallery renders blank until a
/// <c>ClubAdditionalImageEntity</c> lands.</summary>
[HttpPost("/club/{clubId:long}/additionalimage/{slot:int}")]
[HttpPut("/club/{clubId:long}/additionalimage/{slot:int}")]
[Authorize]
@@ -709,35 +753,64 @@ public class ClubsController(ClubService clubs) : ControllerBase
// ── members/bulk + clubreporting ─────────────────────────────────
/// <summary>GET <c>/members/bulk?clubId=N&amp;playerIds=A&amp;playerIds=B</c>
/// — bulk membership lookup. Wire shape is
/// <c>List&lt;JHMMGLNJHIB&gt;</c> with each entry carrying
/// <c>AccountId</c> + <c>MembershipType</c>.</summary>
/// <summary>GET/POST <c>/members/bulk?id=A&amp;id=B</c> — bulk membership
/// lookup for nameplate club badges. Response rows are
/// <c>FCKGOFHNDNJ</c>: <c>AccountId</c> + <c>MembershipType</c>
/// (reader <c>FMKALNIKMKF.txt</c>).
///
/// Two things the original handler got wrong, both visible at
/// <c>IKMMOCKDKAF.txt:20976-21005</c>:
/// * the account ids ride in repeated <c>id</c> params (:20997), not
/// <c>playerIds</c>;
/// * <c>clubId</c> is NEVER transmitted — it is only the client's local
/// cache key — so hard-requiring it 400'd every single call.
/// The verb is picked at runtime by a cmov (:20984-20985,
/// <c>cmp rdi,100 / cmovge edx,eax</c> with eax=2): GET under 100 ids,
/// POST at 100+. Both are registered.
///
/// With no club scope the answer is each account's most privileged
/// membership across all clubs, which is what the badge renders anyway;
/// an explicit <c>clubId</c> (2020 watch, admin tools) still narrows it.
/// </summary>
[HttpGet("/members/bulk")]
[HttpPost("/members/bulk")]
public async Task<IActionResult> MembersBulk()
{
var form = Request.HasFormContentType ? Request.Form : null;
if (!long.TryParse(Request.Query["clubId"].ToString(), out var clubId)
&& (form is null || !long.TryParse(form["clubId"].ToString(), out clubId)))
return BadRequest(new { error = "missing_club_id" });
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
var idsRaw = form is null
? Request.Query["playerIds"].AsEnumerable()
: Request.Query["playerIds"].Concat(form["playerIds"]);
var ids = idsRaw
IEnumerable<string?> Values(string key) => form is null
? Request.Query[key].AsEnumerable()
: Request.Query[key].Concat(form[key]);
var ids = new[] { "id", "playerIds", "accountIds" }
.SelectMany(Values)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(s => long.TryParse(s, out var n) ? n : 0)
.Where(n => n != 0)
.Distinct()
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await clubs.MembershipsBulkAsync(clubId, ids);
return Ok(rows.Select(m => new
{
AccountId = (int)m.PlayerId,
MembershipType = ClubService.MembershipTypeFromPerms(m.Permissions),
}));
long? clubId = long.TryParse(Request.Query["clubId"].ToString(), out var q) ? q
: form is not null && long.TryParse(form["clubId"].ToString(), out var f) ? f
: null;
var rows = clubId is long scope
? await clubs.MembershipsBulkAsync(scope, ids)
: await db.ClubMemberships.AsNoTracking()
.Where(m => ids.Contains(m.PlayerId))
.ToListAsync();
// One row per account: highest membership type wins (Creator > CoOwner >
// Moderator > Member), and ban/pending markers lose to a real role.
return Ok(rows
.GroupBy(m => m.PlayerId)
.Select(g => new
{
AccountId = (int)g.Key,
MembershipType = g.Max(m => ClubService.MembershipTypeFromPerms(m.Permissions)),
})
.ToList());
}
// NOTE: POST /api/clubreporting/v1/report is served by
@@ -749,15 +822,25 @@ public class ClubsController(ClubService clubs) : ControllerBase
// ── Announcements (per-club feed + per-announcement) ─────────────
/// <summary>GET <c>/announcements/club/{clubId}</c> — full
/// announcement feed for the club. Wire shape mirrors the unread
/// rollup (<c>HPACLJHLHBG</c> envelope) but holds every visible
/// announcement, newest first.</summary>
/// <summary>GET <c>/announcements/club/{clubId}</c> — full announcement
/// feed for one club, newest first.
///
/// This returns a SINGLE envelope object, never an array: 2023 reads it as
/// <c>FGLDKEJLAKB&lt;FIAKMDGGIHH&gt;</c>
/// (<c>IKMMOCKDKAF.txt:1170</c>, route at :1452) and the 2020 watch as
/// <c>IPromise&lt;HPACLJHLHBG&gt;</c> (<c>JDJGIBLMFKK.txt:1012,1167</c>).
/// Handing either of them the per-club LIST that the unread rollup uses
/// failed the reader, so the club announcement board never rendered — and
/// a club with no announcements produced <c>[]</c>, not an object at
/// all.</summary>
[HttpGet("/announcements/club/{clubId:long}")]
public async Task<IActionResult> AnnouncementsForClub(long clubId)
{
var rows = await clubs.AnnouncementsForClubAsync(clubId);
return Ok(GroupByClub(rows));
var lastRead = ControllerBaseExtensions.CurrentPlayerId(User) is long pid
? await LastReadByClubAsync(pid, new[] { clubId })
: new Dictionary<long, long>();
return Ok(AnnouncementEnvelope(clubId, rows, lastRead.GetValueOrDefault(clubId)));
}
/// <summary>GET <c>/announcements/club/{clubId}/{aid}</c> — single
@@ -877,37 +960,110 @@ public class ClubsController(ClubService clubs) : ControllerBase
}
return Ok(new
{
// PIHMJGCGNLP deserializer at PIHMJGCGNLP.txt:143-194 reads
// 7 fields off the response dict, not the 5 the parser-keys
// doc lists. Missing any throws KeyNotFoundException —
// CustomTags (List<String>) and AdditionalImages
// (List<FKFAKOKIEGN>) need to be present even if empty.
// All seven keys are mandatory — 2020's PIHMJGCGNLP deserializer
// (PIHMJGCGNLP.txt:143-194) throws KeyNotFoundException on a missing
// one, and 2023's LCLFBBPEMIH registers exactly these names
// (JAHJGFHFKIB.txt:523-678). AdditionalImages stays empty until the
// gallery slots get a table; see ClubAdditionalImage.
Club = ToWireClub(club, memberCount),
CustomTags = Array.Empty<string>(),
CustomTags = await CustomTagsAsync(clubId),
AdditionalImages = Array.Empty<object>(),
CoownerPermissions = PermissionsForRole(ClubService.MembershipTypeCoOwner),
ModeratorPermissions = PermissionsForRole(ClubService.MembershipTypeModerator),
MemberPermissions = PermissionsForRole(ClubService.MembershipTypeMember),
CoownerPermissions = PermissionsForRole(clubId, ClubService.MembershipTypeCoOwner),
ModeratorPermissions = PermissionsForRole(clubId, ClubService.MembershipTypeModerator),
MemberPermissions = PermissionsForRole(clubId, ClubService.MembershipTypeMember),
MyMembershipType = myType,
});
}
/// <summary>
/// Bitmask of capabilities a given role has on a club. The bits
/// are arbitrary (the wire enum <c>JHEEFBMODPG</c> is opaque in
/// the readable dump) but stay consistent so the watch's
/// role-permissions screen renders the same checklist on every
/// refresh. Owner / Coowner can manage all; Moderator can manage
/// content; Member can read.
/// </summary>
private static int PermissionsForRole(int membershipType) => membershipType switch
/// <summary>The club's player-authored tags, for the envelope's
/// <c>CustomTags</c> (<c>List&lt;String&gt;</c> on both clients). These used
/// to be a hardcoded empty array; they are stored in the existing club↔tag
/// junction so the value the client PUT actually comes back.</summary>
private Task<List<string>> CustomTagsAsync(long clubId) =>
(from a in db.ClubCategoryAssignments
join t in db.ClubCategoryTags on a.CategoryTagId equals t.Id
where a.ClubId == clubId
orderby t.OrderIndex, t.Name
select t.Name).ToListAsync();
/// <summary>Replace a club's tag assignments wholesale — the client always
/// PUTs the complete <c>customTags</c> set, never a delta.</summary>
private async Task ReplaceCustomTagsAsync(long clubId, List<string> tags)
{
ClubService.MembershipTypeOwner => 0x7FFF, // everything
ClubService.MembershipTypeCoOwner => 0x7FFE, // all except disband
ClubService.MembershipTypeModerator => 0x00FF, // moderation only
ClubService.MembershipTypeMember => 0x0007, // post + read + react
_ => 0x0001, // read-only
};
var wanted = tags
.Select(t => (t ?? string.Empty).Trim())
.Where(t => t.Length > 0 && t.Length <= 64)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var stale = await db.ClubCategoryAssignments
.Where(a => a.ClubId == clubId)
.ToListAsync();
db.ClubCategoryAssignments.RemoveRange(stale);
foreach (var name in wanted)
{
var lower = name.ToLower();
var tag = await db.ClubCategoryTags.FirstOrDefaultAsync(t => t.Name.ToLower() == lower);
if (tag is null)
{
// A player-authored tag is not part of the admin-curated
// category list, so it lands Active=false: assignments still
// resolve by id, but /club/categoryTags keeps returning only
// the curated set the browse tab is built from.
tag = new ClubCategoryTagEntity { Name = name, OrderIndex = 1000, Active = false };
db.ClubCategoryTags.Add(tag);
await db.SaveChangesAsync();
}
db.ClubCategoryAssignments.Add(new ClubCategoryAssignmentEntity
{
ClubId = clubId,
CategoryTagId = tag.Id,
});
}
await db.SaveChangesAsync();
}
/// <summary>
/// The permission record for one role on a club.
///
/// These three envelope keys used to carry a plain bitmask int. Both
/// clients declare them as a COMPLEX OBJECT — 2023
/// <c>LCLFBBPEMIH.JNOKOILEFHD/OMMHPBFEHBH/IJKKPOBDJGG</c> are
/// <c>MMOCDPPONNG</c> (recnet-runtime-decomp/LCLFBBPEMIH.cs:64-115) and the
/// 2020 watch's <c>PIHMJGCGNLP</c> uses the identically-laid-out
/// <c>JHEEFBMODPG</c> — so feeding an integer into the object reader threw
/// and took the whole club page (and club creation) down with it.
///
/// Key names are verbatim from the generated reader
/// <c>BPHCOIBNCDP.txt:587-766</c>: ClubId, <b>Type</b> (not
/// "MembershipType"), EditDetails, ApproveMember, CreateEvent,
/// PostAnnouncement, EditPermissionSettings, BanUnban. The same six names
/// are the form keys the client PUTs back on the permissions screen
/// (<c>IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173-253</c>).
///
/// The bools are derived from the role rather than stored: there is no
/// per-role permission table yet, so every club uses the default policy —
/// Owner/CoOwner do everything, Moderator moderates but cannot re-write the
/// permission settings, Member and below are read-only.
/// </summary>
private static object PermissionsForRole(long clubId, int membershipType)
{
var admin = membershipType is ClubService.MembershipTypeOwner
or ClubService.MembershipTypeCoOwner;
var moderator = admin || membershipType == ClubService.MembershipTypeModerator;
return new
{
ClubId = clubId,
Type = membershipType,
EditDetails = admin,
ApproveMember = moderator,
CreateEvent = moderator,
PostAnnouncement = moderator,
EditPermissionSettings = admin,
BanUnban = moderator,
};
}
private static object ToWireMembership(ClubMembershipEntity row) => new
{
@@ -947,6 +1103,14 @@ public class ClubsController(ClubService clubs) : ControllerBase
Joinability = c.Joinability,
AllowJuniors = c.AllowJuniors,
MemberCount = memberCount,
// MinLevel + ClubChatEnabled are registered by the 2023 Club reader
// (BLIBJIHOENF.txt:1234-1322) and were never emitted, so every club came
// back with chat disabled on the profile Clubs tab. Neither has a column
// on ClubEntity yet — the client-facing setters are club/{0}/minlevel
// and club/{0}/clubChatEnabled (IKMMOCKDKAF.txt:16561,25779) — so they
// report the permissive defaults until the schema catches up.
MinLevel = 0,
ClubChatEnabled = true,
IsRRO = c.IsRRO,
ClubhouseRoomId = c.ClubhouseRoomId,
ClubType = c.ClubType,
@@ -1011,28 +1175,74 @@ public class ClubsController(ClubService clubs) : ControllerBase
};
/// <summary>
/// Group a flat list of announcements into the per-club
/// <c>HPACLJHLHBG</c> envelope shape. Uses
/// <see cref="Dictionary{TKey, TValue}"/> for the outer object
/// because the wire mixes lowercase and PascalCase keys for the
/// same record (<c>clubId</c> + <c>LastReadAnnouncementId</c> +
/// <c>announcements</c>) and anonymous types can't model that.
/// Group a flat list of announcements into per-club envelopes — the shape
/// the two unread rollups return as a LIST (2023
/// <c>List&lt;NCHLBFPHFJE&gt;</c>, 2020 <c>List&lt;EIKPFIDCKNE&gt;</c>).
/// </summary>
private static List<Dictionary<string, object?>> GroupByClub(
List<ClubAnnouncementEntity> rows)
List<ClubAnnouncementEntity> rows, Dictionary<long, long> lastReadByClub)
{
return rows
.GroupBy(a => a.ClubId)
.Select(g =>
{
var ordered = g.OrderByDescending(a => a.CreatedAt).ToList();
return new Dictionary<string, object?>
{
["clubId"] = g.Key,
["LastReadAnnouncementId"] = (long?)null,
["announcements"] = ordered.Select(ToWireAnnouncement).ToList(),
};
})
.Select(g => AnnouncementEnvelope(g.Key, g.ToList(), lastReadByClub.GetValueOrDefault(g.Key)))
.ToList();
}
/// <summary>
/// One club's announcement envelope. Uses
/// <see cref="Dictionary{TKey, TValue}"/> rather than an anonymous type
/// because the wire mixes casings inside a single record: the 2020 readers
/// match key names EXACTLY and want <c>clubId</c> + <c>announcements</c>
/// lowercase but <c>LastAnnouncementId</c>/<c>LastReadAnnouncementId</c>
/// PascalCase (<c>EIKPFIDCKNE.txt</c>, <c>HPACLJHLHBG.txt:270-280</c>).
/// The 2023 readers are tri-cased (<c>CAKCKAMAAPP.txt:331-414</c>,
/// <c>AECJMONHBHM.txt:267-326</c>) so camelCase satisfies them too.
///
/// Two fixes over the original projection:
/// * <c>LastAnnouncementId</c> was omitted entirely though both readers
/// register it;
/// * <c>LastReadAnnouncementId</c> was an explicit JSON <c>null</c> into
/// a NON-nullable Int64 field on both clients. Always a number now.
/// </summary>
private static Dictionary<string, object?> AnnouncementEnvelope(
long clubId, List<ClubAnnouncementEntity> rows, long lastReadId)
{
var ordered = rows.OrderByDescending(a => a.CreatedAt).ThenByDescending(a => a.Id).ToList();
return new Dictionary<string, object?>
{
["clubId"] = clubId,
["LastAnnouncementId"] = ordered.Count == 0 ? 0L : ordered.Max(a => a.Id),
["LastReadAnnouncementId"] = lastReadId,
["announcements"] = ordered.Select(ToWireAnnouncement).ToList(),
};
}
/// <summary>
/// Highest announcement id the caller has actually read, per club — the
/// <c>LastReadAnnouncementId</c> the envelopes above must carry. Done as
/// two index-supported reads plus an in-memory join for the same reason
/// <see cref="ClubService"/> avoids the LEFT JOIN: EF Core generates
/// pathological SQL for the anti-join on SQLite.
/// </summary>
private async Task<Dictionary<long, long>> LastReadByClubAsync(
long playerId, IEnumerable<long> clubIds)
{
var ids = clubIds.Distinct().ToList();
if (ids.Count == 0) return new Dictionary<long, long>();
var readIds = await db.ClubAnnouncementReads
.Where(r => r.PlayerId == playerId)
.Select(r => r.AnnouncementId)
.ToListAsync();
if (readIds.Count == 0) return new Dictionary<long, long>();
var read = await db.ClubAnnouncements
.Where(a => ids.Contains(a.ClubId) && readIds.Contains(a.Id))
.Select(a => new { a.ClubId, a.Id })
.ToListAsync();
return read
.GroupBy(a => a.ClubId)
.ToDictionary(g => g.Key, g => g.Max(a => a.Id));
}
}