diff --git a/DorkNet.Server.Tests/ClientRouteCoverageTests.cs b/DorkNet.Server.Tests/ClientRouteCoverageTests.cs new file mode 100644 index 0000000..39496a9 --- /dev/null +++ b/DorkNet.Server.Tests/ClientRouteCoverageTests.cs @@ -0,0 +1,110 @@ +using System.Text.RegularExpressions; + +namespace DorkNet.Server.Tests; + +/// +/// 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. +/// data/client-routes-2023.tsv is the recovered inventory of what it +/// asks for — see docs/recroom-2023-client-api-complete.md 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 . 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. +/// +public sealed class ClientRouteCoverageTests +{ + /// Client routes with no handler yet. Each entry is + /// VERB route exactly as it appears in the inventory file. + private static readonly HashSet 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(); + var wrongVerb = new List(); + + 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)} + """); + } + + /// Collapse a route to a comparable shape: no host, no leading or + /// trailing slash, no query, and every parameter — the client's {0} + /// and the server's {id:long} alike — reduced to *. + 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; + } +} diff --git a/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj b/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj index 71ce8e3..12a6c5e 100644 --- a/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj +++ b/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj @@ -24,4 +24,9 @@ + + + + + diff --git a/DorkNet.Server.Tests/EndpointContractDiscovery.cs b/DorkNet.Server.Tests/EndpointContractDiscovery.cs index abc75c9..44a68f1 100644 --- a/DorkNet.Server.Tests/EndpointContractDiscovery.cs +++ b/DorkNet.Server.Tests/EndpointContractDiscovery.cs @@ -36,7 +36,20 @@ public static partial class EndpointContractDiscovery { var httpAttrs = method.GetCustomAttributes(inherit: true) .OfType() + .Cast() .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() + .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) diff --git a/DorkNet.Server.Tests/RoomConsumablesShopTests.cs b/DorkNet.Server.Tests/RoomConsumablesShopTests.cs index 7445e36..b5a65eb 100644 --- a/DorkNet.Server.Tests/RoomConsumablesShopTests.cs +++ b/DorkNet.Server.Tests/RoomConsumablesShopTests.cs @@ -95,7 +95,12 @@ public sealed class RoomConsumablesShopTests : IClassFixture) 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 : IClassFixtureroutesubsystem +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 diff --git a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs index d04d854..e357091 100644 --- a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs @@ -21,6 +21,14 @@ public class CustomAvatarItemsController( IObjectStorage storage, ILogger logger) : ControllerBase { + /// Floor price for a publicly listed custom item. The client + /// asks for this over the wire (api/customAvatarItems/v1/minPriceForPublicItem, + /// 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. + private const int MinPublicItemPrice = 100; + [HttpGet] [AllowAnonymous] public async Task 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? ItemIds { get; set; } } + /// The designer's in-progress draft, as persisted to the + /// per-player caidesign_p<id>.json object (see + /// ). 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 + /// . + /// + /// is the tombstone: + /// 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. + 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; } diff --git a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs index 6448d04..f885950 100644 --- a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs +++ b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs @@ -387,19 +387,59 @@ public class InventionsController( /// List → Dictionary cast inside Util.Deserialize<T> /// 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. + /// dorm-load destabilisation we've been hunting. + /// + /// The 2023 DTO reads a THIRD list, TrendingFilters + /// (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 () 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 + /// AEBEPCMAABC reads only Pinned/Popular, so the extra key is + /// inert there. [HttpGet("api/inventions/v1/tagfilters")] - public IActionResult TagFilters() + public async Task 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 SplitTags(string csv) => csv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + static List Rank(IEnumerable 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)); } - /// POST api/storefronts/v1/trialInvention?inventionId={id} - /// — start a free trial of a store invention. Client - /// (RecNet.Runtime DCFKEFHJAGC.PPCLLFMHJLD(long)) expects a SINGLE - /// Invention DTO back (same shape it uses for - /// search), which it spawns for the trial window (length from - /// trialInvention/duration). Hosted here — not in + /// POST api/storefronts/v1/trialInvention — start a free + /// trial of a store invention, spawned for the window returned by + /// trialInvention/duration. Hosted here — not in /// StorefrontsController — so it can reuse the invention wire builder. - /// We record the trial start so it's an auditable action. + /// 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 + /// AFGEDDANEKP("inventionId", …), and on a non-GET those pairs go + /// into an HTTPUrlEncodedForm body (see the id-list block comment above), so + /// [FromQuery] bound 0 and every trial 404'd. (2) The reply is the + /// Status/Invention/InventionVersion envelope: the continuation at + /// DCFKEFHJAGC.txt:9086 is a + /// Func<BDNCJIPHHOK, FGLDKEJLAKB<IFJONDCAKKM>> which + /// projects .Invention — a bare invention left that null. [HttpPost("api/storefronts/v1/trialInvention")] [Authorize] - public async Task TrialInvention([FromQuery] long inventionId) + public async Task 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)); } + /// GET api/inventions/v1/details — the ONLY key either + /// client reads off this response is Tags: the 2023 continuation is + /// Action<OIABGAKJABE> (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 + /// {Tag:String, Type:Int32} (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. + /// Type is always 0: merges the client's + /// AutoTags and CustomTags into one + /// column, so the auto/custom distinction is not recoverable without a + /// second column. [HttpGet("api/inventions/v1/details")] public async Task 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), }); } + /// GET api/inventions/v1/personaldetails/{id} — the caller's + /// own relationship to the invention. Both clients read a single key here: + /// the 2023 continuation is FGLDKEJLAKB<CEAFHBOOBKL> + /// (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. [HttpGet("api/inventions/v1/personaldetails/{id:long}")] [Authorize] public async Task 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; } + /// RecNet.NewInventionRequestDTO carries chipsCost and + /// cloudVariablesCost too (2023.06 dump.cs:1283258-1283259). + /// 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. + 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)); } + /// Body shape for the POST form of updateprice — + /// RecNet.UpdatePriceRequest, real field names preserved in the 2023.06 + /// dump (dump.cs:1283355-1283356). Settable properties + a parameterless + /// ctor because constructs the + /// instance itself. + public sealed class UpdatePriceRequest + { + public long InventionId { get; set; } + public int Price { get; set; } + } + + /// Both clients POST updateprice 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 JsonUtility.ToJson(UpdatePriceRequest) to + /// FJLLPHFOOJJ, which wraps it in a BestHTTP RawJsonForm + /// (application/json, 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. [HttpGet("api/inventions/v1/updateprice")] [Authorize] - public async Task UpdatePrice( + public Task UpdatePrice( [FromQuery] long inventionId, [FromQuery] int price) + => ApplyPriceAsync(inventionId, price); + + [HttpPost("api/inventions/v1/updateprice")] + [Authorize] + public Task UpdatePricePost( + [ModelBinder(typeof(Binding.FormOrJsonModelBinder))] UpdatePriceRequest req) + => ApplyPriceAsync(req.InventionId, req.Price); + + private async Task 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)); } + /// The 2023 publish call carries FOUR query params, not two: + /// OEGFNFEAAGO.txt:9437/9449/9461/9473 add inventionId, + /// permissionLevel, accessibility and a + /// Nullable<Int32> price — 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. [HttpGet("api/inventions/v3/publish")] [Authorize] public async Task 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( /// The client sends AutoTags and CustomTags as JSON /// ARRAYS of strings. Binding them to string made deserialization /// throw, so every tag edit 400'd. PlayerAddedTags is kept as an - /// alias for older callers. + /// 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 + /// (JsonUtility.ToJson → BNDIAONDFFF.FJLLPHFOOJJ, + /// OEGFNFEAAGO.txt:3082-3088), so [FromBody] is right here. public sealed record SetTagsRequest( long InventionId, List? AutoTags, List? CustomTags, List? PlayerAddedTags); + /// POST api/inventions/v1/settags — the reply is NOT an + /// invention. The 2023 issuing method is typed + /// FGLDKEJLAKB<PNGLFHEAJIH> (OEGFNFEAAGO.txt:2744) and + /// PNGLFHEAJIH exposes exactly two members — an int-backed result enum and + /// List<String> (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. + /// Result = 0 is success: the enum's message formatter jump-tables on + /// the value and case 0 returns "Success!" (PNGLFHEAJIH.txt:334-341). + /// Tags is a flat string list — NOT the {Tag,Type} pairs + /// returns. [HttpPost("api/inventions/v1/settags")] [Authorize] public async Task 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); + /// RecNet.CheerRequest carries a SECOND field — Cheer, the + /// desired state — whose real name survives in the 2023.06 dump + /// (dump.cs:1283338-1283342: long InventionId @0x10, + /// bool Cheer @0x18). Both writes are visible in the ISIL: 2023 sets + /// [rdi+16] and [rdi+24] before JsonUtility.ToJson + /// (OEGFNFEAAGO.txt:11228-11233) and December does the same + /// (2020.12 BBHENFCNLAB.txt:7624-7628) — its wrapper signature is + /// ANJHOOPIAKM(Int64, Boolean) (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. + public sealed record CheerRequest(long InventionId, bool Cheer = true); + /// POST api/inventions/v1/cheer — answers with the + /// Status/Invention/InventionVersion envelope like every other invention + /// mutation: the 2023 method is FGLDKEJLAKB<BDNCJIPHHOK> + /// (OEGFNFEAAGO.txt:11065) and December's is + /// IPromise<AHEPPAEOLOD> (2020.12 BBHENFCNLAB.txt:7503). The old + /// {Id, CheerCount} reply left Invention null on 2023 and threw + /// KeyNotFoundException on December, so the tile never refreshed its + /// count. [HttpPost("api/inventions/v1/cheer")] [Authorize] public async Task 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); + /// POST api/inventions/v1/report — the reply is a + /// success/message pair, not a status flag of our own invention. The 2023 + /// method is FGLDKEJLAKB<PHMHCPEMABG> (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 {Reported:true} left + /// Success at default false, so the report UI reported failure even though + /// the row persisted. Message must be a string — December's + /// Util.GetKey casts it — so send "" rather than null. [HttpPost("api/inventions/v1/report")] [Authorize] public async Task 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); + + /// The 2023 InventionVersion DTO reads NINE keys + /// (OLPHKLCPFEF.txt:084-295): the six we already emitted plus + /// ChipsCost, CloudVariablesCost and BlobHash. + /// 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 (EHENFDMIAIM, 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. + 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, }; + + /// Every invention MUTATION route answers with the + /// Status/Invention/InventionVersion envelope, never a bare invention: the + /// 2023 client deserialises BDNCJIPHHOK (key literals at + /// GMBGBPNMGBA.txt:044-095) and the 2020.12 watch deserialises + /// AHEPPAEOLOD, whose reader pulls the same three literals + /// (2020.12 IsilDump AHEPPAEOLOD.txt:110/115/120). Returning the invention + /// flat left Invention null on 2023 and threw KeyNotFoundException on + /// December. Status = 0 is success — + /// EnterInventionNameDialog.txt:622-624 loads the boxed Status field and + /// does test eax,eax / je <success>. + private async Task 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, + }; + } } diff --git a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs index 9bc0d00..d51bffe 100644 --- a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs @@ -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; /// -/// 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 RecNet.Runtime/CBKANFIOBCF +/// (route literals at CBKANFIOBCF.txt:161-4156). Three wire types drive +/// nearly every handler here: +/// * HPIOAGDJHDH (PlayerEvent) — 17 keys, formatter +/// HPMMKFGDAEC.txt:1139-1518. +/// * MDCBEPJCJPO (PlayerEvent + Tags:[{Tag,Type}]) — formatter +/// CENEMCMGDKG.txt:1191-1586, tag entry keys +/// PCMLHLIBLNJ.txt:191-218. +/// * PHHAKLPGNGC ({PlayerEvent,Result,TagModifyResult}) — +/// formatter EEAAGIJHLOJ.txt:267-326, TagModifyResult keys +/// ALJPHDEAHBK.txt:191-218. Every v2 create/edit/delete/field +/// mutation deserialises this wrapper, so a flat event body leaves the +/// client's PlayerEvent null after a save. /// [ApiController] [Authorize] @@ -22,14 +37,23 @@ public class PlayerEventsController( { private long Me => this.RequireCurrentPlayerId(); - // ── Browse (v2) ────────────────────────────────────────────────────── + /// PlayerEventResponseType.NotGoing — the one RSVP state that + /// does not count towards AttendeeCount. + private const int NotGoingResponse = 2; - /// GET api/playerevents/v2 — 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). + // ── Browse ─────────────────────────────────────────────────────────── + + /// GET api/playerevents/v2 (and v1) — upcoming + /// events list, a BARE JSON array of PlayerEvent. + /// + /// The 2023 watch's Events tab reads the unversioned path: HIHEFBMPOGC + /// returns List<HPIOAGDJHDH> and dispatches with verb 0 + /// (CBKANFIOBCF.txt:1305, route literal :1304). Only + /// [HttpPost] was registered on api/playerevents/v1, so the + /// whole tab 405'd. [AllowAnonymous] [HttpGet("api/playerevents/v2")] + [HttpGet("api/playerevents/v1")] public async Task 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)); } + /// GET api/playerevents/v1|v2/{id}. The client calls this + /// three ways — plain (NLEFILEAMIP → HPIOAGDJHDH) and with + /// ?includeDetails= / ?clubId= (HLJBCGMONEH / OAKKDBLNKLG → + /// MDCBEPJCJPO, query keys at + /// CBKANFIOBCF_NestedType_DEIBMMABHIK.txt:86,99). Both DTOs are the + /// same object, the detail one just adds Tags, and the Utf8Json + /// formatters skip unknown keys — so we always emit the superset and no + /// branch is needed. [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)); } + /// GET api/playerevents/v1/search. Query keys are + /// query, sort and scheduleFilter + /// (CBKANFIOBCF_NestedType_IONCOJJGHNJ.txt:99,113,126); the latter + /// two are enum ordinals (PJBLEKMMACM / BJBLPLKMLBE). [AllowAnonymous] [HttpGet("api/playerevents/v1/search")] - [HttpGet("api/playerevents/v1/searchlive")] public async Task 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)); + } + + /// GET api/playerevents/v1/searchlive — "happening now". + /// Separate handler from because the response element + /// type differs: FIKHCEPDIBF returns List<CLDDIKOJMAM>, which + /// is the PlayerEvent object plus PlayerCount (Int32) and + /// IsFull (Boolean) — formatter PDIFGFKAMBG.txt:1267,1294. + /// Head-count comes from the room's live GameSessions rows (their RoomId is + /// the numeric room id as a string, see + /// Services/GameSessionService.cs:91-96). + [AllowAnonymous] + [HttpGet("api/playerevents/v1/searchlive")] + public async Task 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)); } + /// GET api/playerevents/v1/club/{clubId}. PHNEDJAPIKI + /// returns IOKLNPFOLGI — an OBJECT + /// {Events:[PlayerEvent],ContinuationToken:String} + /// (GHBIGGJKPFI.txt:203-238), not a bare array, and passes + /// take / continuationToken query params + /// (CBKANFIOBCF_NestedType_PKDDOCBDBIM.txt:80,89). 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). [AllowAnonymous] [HttpGet("api/playerevents/v1/club/{clubId:long}")] - public async Task ForClub(long clubId) + public async Task 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()); + 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()); + // 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, + }); } + /// GET api/playerevents/v1/clubs — events for a specific + /// set of clubs. IEGLMOOPLED appends one repeated id query value per + /// club (CBKANFIOBCF_NestedType_LKJFABHPIAN.txt:59); ignoring them + /// returned every club's events and the watch could not bucket the list. + /// [AllowAnonymous] [HttpGet("api/playerevents/v1/clubs")] public async Task 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()); - 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(); + 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? Ids { get; set; } } + /// POST api/playerevents/v1/bulk — verb 2 at + /// CBKANFIOBCF.txt:660; the form field is Ids + /// (CBKANFIOBCF_NestedType_PIKPJPBHOAC.txt:64). GET is kept for the + /// 2020 client, which puts the ids in the query string. [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)); } + /// GET api/playerevents/v1/tagfilters — AKCLLEJNFFD reads + /// three String lists: PinnedFilters, PopularFilters and + /// TrendingFilters (HMDONHHKKGA.txt:279-346). A missing key + /// leaves the list null and the chips UI NREs when it enumerates. [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" }, }); /// GET api/playerevents/v1/all — the watch's - /// LocalPlayerEventInfo fetch (caller's created + RSVP'd - /// events). Response keys (both required, via - /// Util.GetObjectListKey): Created, Responses. + /// LocalPlayerEventInfo fetch (caller's created + RSVP'd events). + /// PCELKKHNPHJ is {Created:[PlayerEvent], Responses:[COAGAJPELCG]} + /// (ENJDEONHNMO.txt:191-218) where each response entry is + /// {PlayerEvent, PlayerEventResponse} (HJKLIIPPPEM.txt:215-258) + /// — a flat response row leaves PlayerEvent null and the My Events + /// page NREs. [AllowAnonymous] [HttpGet("api/playerevents/v1/all")] public async Task AllPlayerEvents() @@ -180,99 +287,477 @@ public class PlayerEventsController( var pid = this.CurrentPlayerId(); if (pid is not long me) return Ok(new { Created = Array.Empty(), Responses = Array.Empty() }); - - 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 AllPlayerEventsForAccount(long accountId) + public Task AllPlayerEventsForAccount(long accountId) + => AllForPlayerAsync(accountId); + + private async Task 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(); + 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(), + }); } - /// POST api/playerevents/v1/{eventId}/responses — - /// alternate RSVP route the watch uses for the response-list - /// flow (older / sub-room view). Body is the same RsvpRequest - /// as v1/{id}/rsvp; behaves identically. - [HttpPost("api/playerevents/v1/{eventId:long}/responses")] - public Task RespondList(long eventId, [FromBody] RsvpRequest body) - => Rsvp(eventId, body); - - /// Wire shape verified against - /// Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815: - /// PascalCase keys PlayerEventId, Name, Description, - /// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId, - /// ImageName, Accessibility. Server-side we store these - /// under different names (Title, StartsAt, EndsAt, Capacity) — - /// remap explicitly here so the watch's - /// PlayerEvent.Deserialize picks them up. - private static object ToWire(PlayerEventEntity ev) => new + /// GET api/playerevents/v1/{eventId}/responses — the event + /// detail page's attendee list. DFABBIDLDPE dispatches with verb 0 + /// (CBKANFIOBCF.txt:1699, route literal :1689) and reads + /// List<JPIKCIGABBI>; the path was registered POST-only, so + /// the list 405'd. + [AllowAnonymous] + [HttpGet("api/playerevents/v1/{eventId:long}/responses")] + public async Task 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 ─────────────────────────────────────────────────────── + + /// The PlayerEvent fields the 2023 client carries that + /// has no column for. Adding columns needs + /// a migration in Data/Entities, so these live in the PlayerSettings + /// side-table: one JSON row per event under + /// playerevent:{id}:wire, plus a flat playerevent:{id}:club + /// 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. + public sealed class EventExtras + { + public long? SubRoomId { get; set; } + public long? ClubId { get; set; } + public string ImageName { get; set; } = string.Empty; + /// 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. + 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 Tags { get; set; } = []; + } + + private static string ExtrasKey(long eventId) => $"playerevent:{eventId}:wire"; + private static string ClubKey(long eventId) => $"playerevent:{eventId}:club"; + + /// Wire shape verified against the client's Utf8Json formatter + /// HPMMKFGDAEC.txt:1139-1518 (and the details variant + /// CENEMCMGDKG.txt:1191-1586): PascalCase keys PlayerEventId, + /// CreatorPlayerId, RoomId, SubRoomId, ClubId, Name, Description, + /// ImageName, StartTime, EndTime, AttendeeCount, Accessibility, + /// IsMultiInstance, SupportMultiInstanceRoomChat, + /// DefaultBroadcastPermissions, CanRequestBroadcastPermissions, + /// BroadcastingRoomInstanceId — property types read off the getters in + /// HPIOAGDJHDH.txt. Server-side we store some of these under other + /// names (Title, StartsAt, EndsAt), so remap explicitly. + private static Dictionary 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(), }; + /// JPIKCIGABBI — {PlayerEventResponseId, PlayerEventId, + /// PlayerId(Int32), CreatedAt, Type}, keys at + /// DDDLCGDEPNH.txt:395-502, types off the getters in + /// JPIKCIGABBI.txt. + private static object ResponseWire(PlayerEventResponseEntity r) => new + { + PlayerEventResponseId = r.Id, + PlayerEventId = r.EventId, + PlayerId = (int)r.PlayerId, + r.CreatedAt, + Type = r.Response, + }; + + /// PHHAKLPGNGC — the wrapper every v2 mutation deserialises. + /// + 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 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>> ToWireManyAsync(IReadOnlyList 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>> ToWireMapAsync( + IReadOnlyList rows) + { + var wire = await ToWireManyAsync(rows); + var map = new Dictionary>(); + for (var i = 0; i < rows.Count; i++) map[rows[i].Id] = wire[i]; + return map; + } + + private async Task> ToWireOneAsync(PlayerEventEntity ev) + => (await ToWireManyAsync(new[] { ev }))[0]; + + private async Task> LoadExtrasAsync(IReadOnlyCollection events) + { + var map = new Dictionary(); + 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(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> AttendeeCountsAsync(IReadOnlyCollection eventIds) + { + if (eventIds.Count == 0) return new Dictionary(); + 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 ──────────────────────────────────────────────────── + + /// '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. + private static IQueryable ApplyScheduleFilter( + IQueryable 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), + }; + } + + /// 'sort' (PJBLEKMMACM) — same UNVERIFIED-ordinal caveat as + /// ; 0/absent keeps soonest-first. + private static IQueryable ApplySort( + IQueryable rows, int? sort) => sort switch + { + 1 => rows.OrderByDescending(e => e.CreatedAt), + 2 => rows.OrderBy(e => e.EndsAt), + _ => rows.OrderBy(e => e.StartsAt), + }; + + /// Events attached to any of . 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. + private async Task> ClubEventsAsync( + IReadOnlyCollection clubIds, int skip, int take) + { + var explicitIds = await EventIdsForClubsAsync(clubIds); + IQueryable 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> EventIdsForClubsAsync(IReadOnlyCollection 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> ClubRoomIdsAsync(IReadOnlyCollection 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) ─────────────────────────────────────────────── + + /// POST api/playerevents/v2 — the 2023 create-event flow. + /// KKMMBCDGFFN posts BIEFKAOABMP as a RawJsonForm body (verb 2 at + /// CBKANFIOBCF.txt:2390) whose 14 keys are + /// RoomId, SubRoomId, ClubId, Name, Description, Tags, ImageName, + /// StartTime, EndTime, Accessibility, IsMultiInstance, + /// SupportMultiInstanceRoomChat, DefaultBroadcastPermissions, + /// CanRequestBroadcastPermissions (ACKNGEEHHAE.txt:935-1234), + /// and reads the PHHAKLPGNGC wrapper back. Only [HttpGet] was + /// registered on this path, so creating an event 405'd. + [HttpPost("api/playerevents/v2")] + public async Task 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)); + } + + /// POST api/playerevents/v2/{eventId} — edit-event save. + /// IOEJKIPJFOI posts the same BIEFKAOABMP body with verb 2 + /// (CBKANFIOBCF.txt:2595, route literal :2579); the path was + /// GET-only, so editing 405'd. + [HttpPost("api/playerevents/v2/{eventId:long}")] + public async Task 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)); + } + + /// Copies whatever subset of the BIEFKAOABMP body is present onto + /// . Absent keys are left alone so the same helper + /// serves both create and edit. + 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 }); } - /// POST api/playerevents/v1/respond — set the - /// caller's RSVP state for an event. Response is the - /// PlayerEventResponseType enum (0=Going, 1=Maybe, 2=Pass). - /// Idempotent — re-responding overwrites. - /// - /// Wire return is a BARE int (DGOPHENCPOC enum value: - /// 0=Success, plus various error codes). Returning an object - /// here throws InvalidCastException on the 2020.12 deserialiser - /// — verified at recroom-2020-client-response-contracts.md - /// under the playerevents/v1/respond section. + /// POST api/playerevents/v1/{eventId}/responses — + /// alternate RSVP route the 2020 watch uses for the response-list + /// flow (older / sub-room view). Body is the same RsvpRequest + /// as v1/{id}/rsvp; behaves identically. The 2023 client GETs this + /// path instead — see . + [HttpPost("api/playerevents/v1/{eventId:long}/responses")] + public Task RespondList(long eventId, [FromBody] RsvpRequest body) + => Rsvp(eventId, body); + + /// POST api/playerevents/v1/respond — 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 + /// recroom-2020-client-response-contracts.md. + /// * 2023-03 posts a RawJsonForm body {PlayerEventId, Type} + /// (NKIFBKJALEJ, keys at GFHNJMMBHFD.txt:203-238; body build at + /// CBKANFIOBCF_NestedType_NFCEOKLHDAG.txt:81-112) and reads + /// CEKABGOIOAF, an OBJECT {Result:int} + /// (GJJGDENPOAP.txt:139-150). + /// Branch the response shape on the body encoding the caller actually + /// used — that is the only signal that distinguishes them. [HttpPost("api/playerevents/v1/respond")] - public async Task RespondForm( - [FromForm(Name = "EventId")] long? eventId, - [FromForm(Name = "Response")] int response = 0) + public async Task 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*/); } - /// POST api/playerevents/v1/deleteResponse — drop - /// the caller's RSVP row. Same bare-int wire shape as - /// . + /// POST api/playerevents/v1/deleteResponse — drop the + /// caller's RSVP row. Same two-generation split as + /// . The 2023 body is + /// JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest) + /// (CBKANFIOBCF_NestedType_MDJMHFHPOBO.txt:65-78) whose single field + /// is name-preserved: public long PlayerEventId + /// (2023.06.21 dump.cs:1291301). [HttpPost("api/playerevents/v1/deleteResponse")] - public async Task DeleteResponseForm([FromForm(Name = "EventId")] long? eventId) + public async Task 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*/); } - /// DELETE api/playerevents/v2/delete/{id} — hard - /// delete an event the caller created. + private IActionResult RespondResult(bool legacyForm, int code) + => legacyForm ? Ok(code) : Ok(new { Result = code }); + + /// DELETE/POST api/playerevents/v2/delete/{id} — hard + /// delete an event the caller created. GJMCEDALKJO dispatches with verb 2 + /// (CBKANFIOBCF.txt:4166, route literal :4156) and reads the + /// PHHAKLPGNGC wrapper, so POST plus the wrapper body are both required; + /// DELETE stays for the 2020 client. + [HttpPost("api/playerevents/v2/delete/{id:long}")] [HttpDelete("api/playerevents/v2/delete/{id:long}")] public async Task 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 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)); } + /// PUT api/playerevents/v2/{id}/room — FPPNHCMPAKK sends + /// roomId AND an optional subRoomId + /// (CBKANFIOBCF_NestedType_AKPPDNLCACI.txt:88,101); dropping the + /// latter meant the event never pointed at the sub-room the host picked. + /// [HttpPost("api/playerevents/v2/{eventId:long}/room")] [HttpPut("api/playerevents/v2/{eventId:long}/room")] public async Task 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)); } + /// PUT api/playerevents/v2/{id}/tags. JHLMDMGELFD does NOT + /// send form fields — it serialises List<String> straight into + /// a RawJsonForm body, i.e. a BARE JSON ARRAY + /// (CBKANFIOBCF_NestedType_PNFGKCKCGEL.txt:63-81). The old field + /// reader only looked inside JSON objects, so every save stored an empty + /// string and silently cleared the tags. The wrapper's + /// TagModifyResult.Tags is what the client re-reads afterwards. + /// [HttpPost("api/playerevents/v2/{eventId:long}/tags")] [HttpPut("api/playerevents/v2/{eventId:long}/tags")] public async Task 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(); + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + extras.Tags = tags; + await SaveExtrasAsync(evt, extras); + return Ok(await WrapperAsync(evt)); } + /// PUT api/playerevents/v2/{id}/multiinstance. IEGDKKAKHBD + /// sends FOUR form fields — isMultiInstance, + /// supportsMultiInstanceRoomChat, defaultBroadcastPermissions, + /// canRequestBroadcastPermissions + /// (CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:122-161). The handler used + /// to look for a field named multiInstance, 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. [HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")] [HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")] public async Task 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)); } + /// PUT api/playerevents/v2/{id}/club. JLHJHGANFDM takes a + /// Nullable<Int64> and boxes it into the single form field + /// clubId (CBKANFIOBCF_NestedType_DJLDOCJBFPO.txt:60-71) — 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. [HttpPost("api/playerevents/v2/{eventId:long}/club")] [HttpPut("api/playerevents/v2/{eventId:long}/club")] public async Task 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? InvitedPlayerIds { get; set; } } - /// POST /api/playerevents/v1/bulkInvite — wire - /// type per agent ISIL extraction: BulkInviteRequest{PlayerEventId, - /// InvitedPlayerIds:List<int>}; response - /// BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}. - /// We insert one MessageEntity per recipient so the watch's inbox - /// shows the invite, and push a per-recipient notification. + /// POST /api/playerevents/v1/bulkInvite. Request is + /// JsonUtility.ToJson(RecNet.Events.BulkInviteRequest) — + /// name-preserved fields PlayerEventId / InvitedPlayerIds + /// (2023.06.21 dump.cs:1288323-1288324, body build at + /// CBKANFIOBCF_NestedType_MIKAHKKCIEE.txt:69-96). Response is + /// IDELKAJIOLI {FailedInvites, Result} + /// (GAODOHODBDE.txt:203-238) where each failed entry is + /// {InvitedPlayerId, Result} (MOJLGDKFECO.txt:203-238) — the + /// entries used to be {PlayerId, Error} and deserialised as all + /// defaults. Error paths return the same JSON shape rather than a + /// NotFound()/Forbid() body the client cannot parse. [HttpPost("api/playerevents/v1/bulkInvite")] public async Task BulkInvite([FromBody] BulkInviteRequest req) { @@ -536,8 +1132,11 @@ public class PlayerEventsController( return Ok(new { FailedInvites = Array.Empty(), 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(), Result = 1 }); + if (ev.CreatorPlayerId != Me) return Ok(new { FailedInvites = Array.Empty(), Result = 1 }); var failed = new List(); 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 + /// POST api/playerevents/v1/broadcast — set or clear the + /// room instance an event is being broadcast from. JBHPMONELAO posts + /// RecNet.Events.BroadcastRoomInstanceRequest + /// (CBKANFIOBCF_NestedType_EHHMGKFGGCC.txt:81) whose JSON keys are + /// PlayerEventId and the nullable BroadcastRoomInstanceId + /// (EOFEKNIHNNC.txt:215-258), 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. + [HttpPost("api/playerevents/v1/broadcast")] + public async Task 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 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 GetOwnedEventAsync(long eventId) - => await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me); - - private async Task> ReadEventIdsAsync() - { - var ids = new List(); - - 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 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 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 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 ReadLongFieldAsync(params string[] names) - { - var value = await ReadStringFieldAsync(names); - return long.TryParse(value, out var parsed) ? parsed : null; - } - - private async Task ReadIntFieldAsync(params string[] names) - { - var value = await ReadStringFieldAsync(names); - return int.TryParse(value, out var parsed) ? parsed : null; - } - - private async Task ReadBoolFieldAsync(params string[] names) - { - var value = await ReadStringFieldAsync(names); - return bool.TryParse(value, out var parsed) ? parsed : null; - } - - private async Task ReadDateFieldAsync(params string[] names) - { - var value = await ReadStringFieldAsync(names); - return DateTime.TryParse(value, out var parsed) ? parsed : null; - } - - private async Task> ReadRequestFieldsAsync() - { - const string itemKey = "__playerevent_fields"; - if (HttpContext.Items.TryGetValue(itemKey, out var cached) - && cached is Dictionary existing) - { - return existing; - } - - var fields = new Dictionary(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(); - } - /// POST api/playerevents/v1/report — file a /// moderation report against an event. Wire response is /// KLAMKCBENEA{Success, Message}; 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 GetOwnedEventAsync(long eventId) + => await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me); + + private async Task> ReadEventIdsAsync() + { + var ids = new List(); + + 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 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 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; + } + + /// 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 Tags), and repeated / + /// comma-joined form values. + private async Task?> 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(); + 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? 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 ElementToStrings(JsonElement array) + { + var list = new List(); + 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 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 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 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 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; + } + + /// Everything the request carries, read once and cached — the body + /// stream can only be consumed a single time. + private sealed class ParsedBody + { + public Dictionary Fields { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary> Repeated { get; } = new(StringComparer.OrdinalIgnoreCase); + /// Set when the whole body is a bare JSON array — the shape + /// the tags PUT sends. + public List? RootArray { get; set; } + } + + private const string BodyItemKey = "__playerevent_body"; + + private async Task 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; + } } diff --git a/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs b/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs index f367967..605fad9 100644 --- a/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs +++ b/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs @@ -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). +/// /// Consumable 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 diff --git a/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs b/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs index e67f675..9b8d2e4 100644 --- a/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs +++ b/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs @@ -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(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; } + /// Collapses a multi-valued query/form key into the comma-joined form + /// / already split on. + /// + /// getPurchaseOffersBatch pushes its currency-id list through the generic batch + /// param helper (0x181F2DCD0 with "ids", + /// MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:228-230), which is the same helper + /// every other RecNet batch route uses to REPEAT the key once per id + /// (?ids=a&ids=b). Taking only FirstOrDefault() silently + /// dropped every id after the first, so a multi-currency room fetched packs for + /// exactly one of its currencies. + 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 ReadJsonBodyAsync() { if ((Request.ContentLength ?? 0) <= 0) return null; diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index c01a713..d4395e4 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -2409,8 +2409,31 @@ public class RoomsController( [HttpGet("api/rooms/v2/myrecent")] public IActionResult MyOtherTabs() => Ok(Array.Empty()); + /// Per-(player, room) persistence blob, base64. Lives in the + /// generic 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 MaxLength(1024) but SQLite stores it as unconstrained + /// TEXT and EF Core does not run DataAnnotations validation on SaveChanges, + /// so oversized blobs round-trip intact. + private static string PlayerRoomDataKey(long roomId) => $"roomplayerdata:{roomId}"; + + /// Fallback blob handed to a player who has never saved anything + /// in this room. CAE= is a one-field protobuf the client parses as + /// "empty, version 1"; a truly empty string makes the 2023 reader throw. + private const string DefaultPlayerRoomData = "CAE="; + + /// GET rooms/{roomId}/playerdata/me — the per-room player + /// blob (CircuitsV2 "player persistence"). Client contract is + /// Task<ILKMFMCOPPO> whose reader requires the key + /// Data (OPNPNPMKLGI.txt ISIL 034/060); the request is issued from + /// NLDBPDCNNCF (NLDBPDCNNCF_NestedType_IGENGEIMGMJ.txt:169), so it arrives + /// with the roomserver/ prefix — the bare-only registration 404'd. + /// This used to return a hardcoded CAE= for everyone; it now returns + /// whatever last stored. [HttpGet("player_room_data/{roomId:long}")] [HttpGet("rooms/{roomId:long}/playerdata/me")] + [HttpGet("roomserver/rooms/{roomId:long}/playerdata/me")] [Authorize] public async Task 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; } } + + /// PUT rooms/{roomId}/playerdata/me — persist the caller's + /// per-room blob. Client method NLDBPDCNNCF.LIHFBBCDAGO(long, string) + /// ("save player room data") moves verb ordinal 3 = PUT into r8 and posts a + /// single form field data + /// (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 Task, so no response body is parsed. + /// + /// Only the GET was registered before, so every save 405'd and per-room + /// player state never persisted. + [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 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 }); + } + + /// GET rooms/requiring/{restriction} — the restricted-rooms + /// list. The path segment is mode.ToString().ToLower() + /// (Matchmaking.txt ISIL 067-072 around :21263, concat onto the literal + /// "rooms/requiring/" at :21264). + /// + /// Element type is String, not Int64 — verified, do not "fix" this + /// to room ids. The issuing method + /// Matchmaking.POIMPHMONON(IOCOJFJEDCM) (Matchmaking.txt:21030) + /// resolves its promise as FGLDKEJLAKB<List<String>> + /// (typeof load at Matchmaking.txt:21327, continuation delegate + /// Action<List<String>> at :21315) and hands the payload to + /// <RequestRoomRestrictions>b__2(List<String> rooms) + /// (Matchmaking_NestedType_PMNEOAMKLJL.txt:14), which forwards it to + /// Matchmaking.IINEGHJJBAN(IEnumerable<String>, IOCOJFJEDCM) + /// (:20758) where the strings become the KEYS of the restriction map + /// (List<IOCOJFJEDCM> 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). [HttpGet("rooms/requiring/{restriction}")] [HttpGet("roomserver/rooms/requiring/{restriction}")] [Authorize] @@ -2708,19 +2802,31 @@ public class RoomsController( /// 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. + /// by the wrong one. + /// + /// 2023 client: NLDBPDCNNCF.LHNDKOJNKBE + /// ("change room name", NLDBPDCNNCF.txt:5069) moves verb ordinal 3 = PUT, + /// sends the lowercase form key name + /// (NLDBPDCNNCF_NestedType_LMADOAEHOPP.txt ISIL 015) and returns + /// Task<FGCPNAACHIK> — the FULL room-details object, not a + /// status wrapper. It also carries the roomserver/ 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 [FromForm] with a [FromBody] + /// parameter, which under [ApiController] makes the form PUT 415 + /// before the handler runs — hence the shared FormOrJsonModelBinder. 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 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 - // PPENFJMFPNE deserialiser inherits from - // KLCOGEIGEBJ (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 (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)); } /// POST rooms/{roomId}/subrooms/{subRoomId}/name — /// rename a scene/sub-room within a multi-scene room. Watch path: /// EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName). /// Updates the matching after - /// verifying ownership and intra-room uniqueness. + /// verifying ownership and intra-room uniqueness. + /// + /// 2023 client: NLDBPDCNNCF.EJOHFBMFEGB + /// ("change subroom name", NLDBPDCNNCF.txt:8694) is verb 3 = PUT with form + /// key name and returns Task<FGCPNAACHIK>. Same + /// [FromForm]+[FromBody] 415 hazard as the room rename above, so it binds + /// through the shared FormOrJsonModelBinder and echoes full details. [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 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)); } /// POST api/rooms/v1/cheer — 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 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 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 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 BareUnfavorite(long roomId) { @@ -3418,10 +3515,20 @@ public class RoomsController( } /// POST rooms/{id}/subrooms — append a new scene. - /// Owner-gated. OrderIndex = max+1, Name unique within room. + /// Owner-gated. OrderIndex = max+1, Name unique within room. + /// + /// 2023 client: NLDBPDCNNCF.CMHBCOIBDME ("add new + /// subroom", NLDBPDCNNCF.txt:9396) is verb 2 = POST with a single form key + /// name (NLDBPDCNNCF_NestedType_AIPHLHBILDN.txt ISIL 015) and returns + /// Task<FGCPNAACHIK>. It needs the roomserver/ twin, a binder + /// that accepts the form body ([FromBody] 415s it), and full room + /// details in the response instead of the scene row. [HttpPost("rooms/{roomId:long}/subrooms")] + [HttpPost("roomserver/rooms/{roomId:long}/subrooms")] [Authorize] - public async Task AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req) + public async Task 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)); } + /// DELETE rooms/{id}/subrooms/{sub}. The 2023 caller + /// NLDBPDCNNCF.HGNGMIMJFJL ("delete subroom", NLDBPDCNNCF.txt:9827) + /// moves verb ordinal 4 = DELETE and returns + /// Task<FGCPNAACHIK>; 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 {Result:0} body was not the + /// details object the client re-parses. [HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")] + [HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")] [Authorize] public async Task 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 + { + /// Target ROOM for the scene — the 2023 "move subroom" flow + /// re-parents a scene into another room. Nullable<Int64> on the + /// wire (NLDBPDCNNCF.MGGALMIAMBG, NLDBPDCNNCF.txt:9659). + public long? NewRoomId { get; set; } + + /// Legacy in-room reorder used by the admin/import tooling. + /// Not something the 2023 client sends. + public int? NewIndex { get; set; } + } + + /// One row of the per-subroom role-permission matrix the 2023 + /// client PUTs to subrooms/{sub}/permissions. Keys verified in the + /// writer HDOJMEBCAGP.txt ISIL 050/069/085/101/117. + 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 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 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); }); + /// PUT rooms/{id}/subrooms/{sub}/accessibility. Client: + /// NLDBPDCNNCF.OAINKEAFPGE(long, long, JHAOFLCJNAL) + /// (NLDBPDCNNCF.txt:9032, verb 3), form key accessibility, response + /// Task<FGCPNAACHIK>. We only model the matchmaking bit + /// (), so Private collapses to + /// "not a matchmaking target" and everything else to "is one". [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 SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) => - MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; }); + public Task 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; + }); + /// PUT rooms/{id}/subrooms/{sub}/permissions — the + /// role-permission matrix. Client: + /// NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>) + /// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT + /// form-encode: the closure calls BNDIAONDFFF.FJLLPHFOOJJ(String) + /// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string + /// (BNDIAONDFFF.txt:275, mov dword ptr [rbx+38h],4) — i.e. a raw + /// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON + /// deserialize into the List). + /// + /// Scope note — deliberately not persisted. DorkNet has no + /// per-subroom permission storage: 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. [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 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 SubRoomPermissions( + long roomId, + long subRoomId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List 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 (NLDBPDCNNCF.txt:10476), + // so the scene row alone made a successful restore read as a failure. + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } private async Task ReadPublishSubRoomSaveRequestAsync() @@ -3640,34 +3871,68 @@ public class RoomsController( }; db.RoomScenes.Add(clone); await db.SaveChangesAsync(); - return Ok(SceneWire(clone)); + // Task ("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 ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) => + public Task 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; }); - /// POST rooms/{id}/subrooms/{sub}/move — reorder a - /// scene. Body NewIndex is the target OrderIndex; the in-between - /// rows shift to fill the gap. + /// POST rooms/{id}/subrooms/{sub}/move. + /// + /// The 2023 semantics are "move to another ROOM", not "reorder". + /// NLDBPDCNNCF.MGGALMIAMBG(long roomId, long subRoomId, + /// Nullable<Int64> newRoomId) ("move subroom", + /// NLDBPDCNNCF.txt:9659, verb 2 = POST) sends the single form key + /// newRoomId (COIOGADEBKJ.txt ISIL 013-020, boxed as + /// Nullable<Int64>) and returns + /// Task<FGCPNAACHIK>. The old handler bound + /// [FromBody] {NewIndex} — a key the client never sends, on a body + /// binding that 415s form posts — and reordered scenes in place instead. + /// + /// 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 OrderIndex stays a dense 0..n-1 sequence — the + /// wire RoomSceneId and matchmaking SubRoomId are that index. + /// Scene 0 is the room's entry scene and cannot be moved out. + /// newIndex (no client sends it) keeps the old in-room reorder for + /// the import tooling. [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 MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req) + public async Task 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 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)); + } + + /// Rewrite 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. + private async Task RepackSceneOrderAsync(List 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)); } /// POST roomserver/rooms/{id}/subrooms/{sub}/data — @@ -3762,6 +4077,11 @@ public class RoomsController( return null; } + /// 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 + /// Task<FGCPNAACHIK>, so the old scene-row payload made a + /// successful mutation surface as a client-side parse failure. private async Task MutateScene(long roomId, long subRoomId, Action 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 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 and re-parses the full room details, so all of these + // handlers now go through BuildRoomServerDetailsWithRolesAsync. // ── Bare-path roles ──────────────────────────────────────────────── /// GET rooms/{id}/roles — account role DTOs consumed by - /// 2023's EFHPLDPNGIM deserializer. + /// 2023's EFHPLDPNGIM deserializer + /// (NLDBPDCNNCF.LAJDCNIMBAB → Task<List<EFHPLDPNGIM>>, + /// NLDBPDCNNCF.txt:2111, verb 0 = GET). Like every other NLDBPDCNNCF call it + /// arrives with the roomserver/ prefix, so the bare-only registration + /// 404'd the permissions screen's list fetch. [HttpGet("rooms/{roomId:long}/roles")] + [HttpGet("roomserver/rooms/{roomId:long}/roles")] public async Task 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); } + /// GET rooms/{id}/roles/{playerId} — one role row + /// (NLDBPDCNNCF.PCFOIPELKHN → Task<EFHPLDPNGIM>, + /// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list + /// above. [HttpGet("rooms/{roomId:long}/roles/{playerId:long}")] + [HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] public async Task 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; } } - /// POST rooms/{id}/roles/{playerId} — owner grants - /// a role to a player. Auto-accepted (no invite step). + /// PUT rooms/{id}/roles/{playerId} — owner grants + /// a role to a player. Auto-accepted (no invite step). + /// NLDBPDCNNCF.LMIEIJMCEAB ("change room account role", + /// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key role carrying a + /// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full + /// FGCPNAACHIK details — which this already does. Only the + /// roomserver/ twin was missing. [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( /// POST rooms/{id}/roles/{playerId}/invite — 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. + /// invited rows surface in RoomDetails.InvitedCoOwners etc. + /// NLDBPDCNNCF.KIJDCLINOIC ("invite account to role", + /// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix. [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")] diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index 3c68e06..434cc1a 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -721,43 +721,6 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase return null; } - private async Task ReadJsonElementAsync(params string[] stringKeys) - { - if (Request.HasFormContentType) - { - var form = await Request.ReadFormAsync(); - var dict = new Dictionary(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 }); } + /// PUT rooms/{id}/promo_external — "add room promotional + /// content" (verb byte 3 = PUT at NLDBPDCNNCF.txt:8181). Both the add and + /// the remove closures send exactly two form keys: type — a BOXED + /// System.Int32 (NLDBPDCNNCF/OJAKIAHKALI:017 boxes typeof(System.Int32), + /// key at :022) — and reference, 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. [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 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); }); } + /// DELETE rooms/{id}/promo_external — "remove room + /// promotional content" (verb byte 4 = DELETE at NLDBPDCNNCF.txt:8343). + /// Identity is the (type, reference) 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. [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 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) + /// 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. + /// + 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; } - /// rooms/{id}/comments + voice_chat_encryption - /// — 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. + /// rooms/{id}/comments (form key disable, Boolean — + /// NLDBPDCNNCF/EKLEDLOPLJB:015/:020) and + /// rooms/{id}/voice_chat_encryption (form key + /// encryptVoiceChat, 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. [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 BareAck(long roomId) => - await ApplyAndReturn(roomId, _ => { /* no-op: not modelled yet */ }); + await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ }); - /// rooms/{id}/modify — bare-path equivalent - /// of api/rooms/v2/modify. Reuses by - /// forwarding the body with RoomId pre-populated from the URL. + /// PUT rooms/{id}/modify — 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: name (string, :039), + /// description (string, :049), accessibility (Int32, boxed + /// at :059 / key :063), then eight Booleans — supportsScreens + /// (:076), supportsWalkVR (:089), supportsTeleportVR + /// (:102), supportsJuniors (:115), cloningAllowed (:128), + /// disableMicAutoMute (:141), disableRoomComments (:154) + /// and encryptVoiceChat (:167). + /// + /// The old [FromBody] ModifyRoomRequest forwarder 415'd on that + /// form body before the handler ran, was missing four of the fields, and + /// answered {Result, Room} — 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 above). [HttpPost("rooms/{roomId:long}/modify")] [HttpPut("rooms/{roomId:long}/modify")] - public async Task 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 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)); } /// POST rooms/{id}/clone — 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 BareBansList(long roomId) => RoomBansList(roomId); + /// POST rooms/{id}/bans — "ban players" (verb byte 2 = + /// POST at NLDBPDCNNCF.txt:13060). The client sends + /// x-www-form-urlencoded with ONE banMask (boxed Int32) plus a + /// REPEATED id key — one per account being banned + /// (NLDBPDCNNCF/DMMILEJMMCC:024 and :034; the `id` binder is the + /// list-valued overload, fed from the + /// IReadOnlyList<Int32> parameter of the issuing method at + /// NLDBPDCNNCF.txt:12920). The old [FromBody] BareBanRequest + /// 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. + /// [HttpPost("rooms/{roomId:long}/bans")] - public Task 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 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 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 }); } + /// POST rooms/{id}/bans/import — "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 + /// sourceRoomId (NLDBPDCNNCF/KGDNOLMFENE:015 boxes System.Int64, + /// :020 is the key) — NOT an explicit ban list. The old + /// [FromBody] ImportRoomBansRequest therefore 415'd on the form + /// body AND modelled the wrong contract. The JSON ban-list shape stays + /// available on the legacy api/rooms/v1/importroombans. [HttpPost("rooms/{roomId:long}/bans/import")] - public Task 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 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(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 ApplyAndReturn(long roomId, Action 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)); + } + + /// The response body EVERY bare-path room mutation must return. + /// + /// The 2023 roomserver client dispatches all of them through the same + /// typed helper (0x1835E50A0) with the same response-reader token + /// [0x1884C1948] — 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 + /// ). 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. + private async Task 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); } } diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs index 61d0f41..01196e4 100644 --- a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs @@ -40,6 +40,16 @@ public class StorefrontsBuyController( public int StorefrontType { get; set; } public int PurchasableItemId { get; set; } public int CurrencyType { get; set; } = 2; + + /// 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: Nullable<Int64> at +32 in + /// RecNet.Runtime/DCFKEFHJAGC_NestedType_RequestPurchaseItemDTO.txt:123-137, + /// wire key "RequestedPrice" in the generated JSON contract + /// RecNet.Runtime/FPLGJGDJIJG.txt:534. + 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)); } + /// POST api/storefronts/v2/buyTier — season-pass "skip a + /// tier" button. The tier is an ordinary StoreItems row on the + /// season:{n} storefront (see StorefrontsController.Season), so its + /// real price is charged rather than a flat 1 token, and the purchase is + /// recorded as a seasonTier:* progress row so the season page can + /// count how many tiers the player bought. [HttpPost("api/storefronts/v2/buyTier")] public async Task 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)); } + /// POST api/storefronts/v2/buyElite — 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 + /// seasonElite:* progress row. [HttpPost("api/storefronts/v2/buyElite")] public async Task 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)); } /// GET api/storefronts/v1/buyRoomKey — purchase a /// persisted room key. The 2020.12 client sends roomKeyId and the /// price it displayed so we reject stale-price purchases. [HttpGet("api/storefronts/v1/buyRoomKey")] - [HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")] public async Task 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; } + } + + /// POST api/storefronts/v1/PurchaseRoomKeyWithCurrency — + /// buying a room key with a ROOM currency instead of tokens. + /// + /// This was only registered as a [HttpGet] alias of BuyRoomKey, so + /// every attempt 405'd. The 2023-03-21 client issues it with verb 2 (POST — + /// RecNet.Runtime/DCFKEFHJAGC.txt:7426) and three form fields, + /// RoomKeyId (Int64, :7439), RequestedPrice (Int64, :7451) and + /// RequestedPurchaseCurrencyId (Guid, :7464); the DTO signature is + /// INBGDNMFJBP(Int64, Int64, Guid) at :7206. + /// + /// The response is RoomKeyPurchaseWithCurrencyResponseDTO, whose + /// generated contract has exactly two members — Balance and + /// RoomKeyResponse (RecNet.Runtime/EDMKBBPGJAB.txt:203/222) — + /// where Balance is the ROOM-currency balance row + /// {AccountId, CurrencyId, Balance, ModifiedAt} + /// (RecNet.Runtime/DPNLANKOHON.txt:331-414), NOT the token + /// BalanceUpdateResponse that the GET alias returned. + [HttpPost("api/storefronts/v1/PurchaseRoomKeyWithCurrency")] + public async Task 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)); + } + + /// GET api/storefronts/v2/buyInvention — MakerPen store + /// purchase of a paid invention. Verb is 0/GET + /// (RecNet.Runtime/DCFKEFHJAGC.txt:6772) with query fields + /// inventionId (Int64, :6785) and requestedPrice (Int32, + /// :6797) — the latter was being ignored. + /// + /// The response type is InventionPurchaseResponseDTO, whose contract + /// is {InventionResponse, BalanceUpdateResponse} + /// (RecNet.Runtime/MAHHAAEGEEP.txt:215/242). The handler used to + /// return the bare token-balance wrapper, so InventionResponse 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} + /// (RecNet.Runtime/GMBGBPNMGBA.txt:255/274/290) — the same triple + /// InventionsController already returns from addversion. + [HttpGet("api/storefronts/v2/buyInvention")] [HttpPost("api/storefronts/v2/buyInvention")] - [HttpGet("api/storefronts/v2/buyInvention")] - public async Task BuyInvention([FromQuery] long inventionId, [FromQuery] int currencyType = 2) + public async Task 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)); + } + + /// Body of buyForFreeGiftButton. Unlike its siblings this + /// route does NOT use form fields: the client serialises a + /// RequestPurchaseForFreeGiftButtonDTO and posts it as the whole + /// body (RecNet.Runtime/DCFKEFHJAGC.txt:5910-5924, verb 2 at :5912). + /// Field offsets +16/+24/+40/+44/+48 in + /// DCFKEFHJAGC_NestedType_RequestPurchaseForFreeGiftButtonDTO.txt, + /// wire keys PurchasableItemId / CouponConsumablePlayerMappingId / Count / + /// CreatorPlayerId / RequestedPrice in RecNet.Runtime/IAPMDDFENDD.txt:395-502. + /// + 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; } + } + + /// POST api/storefronts/v1/buyForFreeGiftButton — the + /// in-room "free gift button" gadget handing an item to whoever pressed it. + /// + /// The handler bound [FromQuery] currencyType/price 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 BalanceResponseDTO + /// (DCFKEFHJAGC.txt:6008/6055) whose contract is exactly + /// {Balance, CurrencyType, Platform} — RecNet.Runtime/PECGEJAAMHB.txt:255/274/298. + /// 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. + [HttpPost("api/storefronts/v1/buyForFreeGiftButton")] + [HttpGet("api/storefronts/v1/buyForFreeGiftButton")] + public async Task 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)); + } + + /// Form body of buyProgressionEventXpBoost, verified at + /// RecNet.Runtime/DCFKEFHJAGC.txt:12431 (progressionEventId, Int64), + /// :12444 (purchasableXpBoostId, Guid), :12456 (requestedPrice, Int32) and + /// :12468 (expectedXp, Int32). + public sealed class ProgressionEventXpBoostRequest + { + public long ProgressionEventId { get; set; } + public Guid PurchasableXpBoostId { get; set; } + public int RequestedPrice { get; set; } + public int ExpectedXp { get; set; } + } + + /// POST api/storefronts/v1/buyProgressionEventXpBoost. + /// + /// Two defects: the handler bound [FromQuery] currencyType/price so + /// none of the four form fields arrived (nothing charged, no XP granted), + /// and it reused the shared BalanceUpdateResponse whose + /// BalanceUpdates[].Data is a LIST. This route's response is + /// BalanceUpdateResponseDTO`1<PurchasedProgressionEventXpBoostDTO> + /// (signature at DCFKEFHJAGC.txt:12118) and that family's inner Data + /// is a SINGLE object — get_Data returns DataTypeDTO, not + /// List<DataTypeDTO>, in + /// DCFKEFHJAGC_NestedType_BalanceUpdateResponseDTO`1_NestedType_DEJIAFEKFCK.txt:83 + /// (contrast the Purchase* variant at + /// ..._PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83, + /// which IS a List). An array there makes the reader throw. + /// The payload object is {Xp:Int32} — RecNet.Runtime/NNEJHCKLOHL.txt:139. + [HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")] + [HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")] + public async Task 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 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 BuyProgressionEventXpBoost([FromQuery] int currencyType = 2, [FromQuery] int price = 0) + /// Form body of buyPurchaseReminder, verified at + /// RecNet.Runtime/DCFKEFHJAGC.txt:10203 (purchaseReminderId, Int32) + /// and :10215 (requestedPrice, Int64). + public sealed class PurchaseReminderRequest + { + public int PurchaseReminderId { get; set; } + public long RequestedPrice { get; set; } + } + + /// POST api/storefronts/v1/buyPurchaseReminder — buying the + /// item a "you were looking at this" reminder popup advertises. + /// + /// Was a stub returning {Success:true}; the client deserialises + /// JNPGKDJJPPM = {Balance:Int64, CurrencyType, BalanceType, Data: + /// List<GiftPackage>} (signature at DCFKEFHJAGC.txt:9928, keys at + /// RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386), so every field + /// defaulted and the wallet read 0. Note this DTO really does spell its + /// third member BalanceType, unlike the BalanceUpdateResponse family + /// which renames the same property to Platform. + /// + /// There is no reminder catalogue on a private server — the offer feed + /// (reminder/currentTokenBundles/v2) 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. + [HttpPost("api/storefronts/v1/buyPurchaseReminder")] + [HttpGet("api/storefronts/v1/buyPurchaseReminder")] + public async Task 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 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>. 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() + : new object[] { GiftPackageWire(giftId, currencyType, grantedSlug) }, + }, + }, + }; + } + + /// One FFFIMAGLKEG/FHMABOHAEED 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). + 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, }; + } + /// Bare BalanceResponseDTO — {Balance, CurrencyType, + /// Platform} and nothing else, per the generated contract at + /// RecNet.Runtime/PECGEJAAMHB.txt:255/274/298. BalanceType is kept + /// alongside Platform for the 2020.12 watch, which reads that spelling. + /// + private static object BalanceResponse(long balance, int currencyType) => new + { + Balance = balance, + CurrencyType = currencyType, + Platform = 0, + BalanceType = 0, + }; + + /// BalanceUpdateResponseDTO`1<PurchasedProgressionEventXpBoostDTO>. + /// Data is a SINGLE object here, not a list — see the BuyProgressionEventXpBoost + /// remarks for the ISIL citation. + 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 }, + }, + }, + }; + + /// JNPGKDJJPPM — the buyPurchaseReminder response. + /// {Balance, CurrencyType, BalanceType, Data:List<GiftPackage>} + /// (RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386). "Platform" is + /// emitted as an inert alias because this one DTO is the odd one out that + /// really does use "BalanceType". + 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() + : 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>. - BalanceUpdates = new[] + InventionResponse = new { - new - { - UpdateResponse = 0, // UpdateResponseTypes.Success - Data = grantedSlug is null - ? Array.Empty() - : new object[] { giftPackage }, - }, + Status = status, + Invention = inventionWire, + InventionVersion = versionWire, + }, + // InventionPurchaseResponseDTO/EPFIAEMPKJK is + // BalanceUpdateResponseDTO`1 — 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(), }, }; } + /// 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). + 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, + }; + + /// 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. + 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(), }, }; + + /// RoomKeyPurchaseWithCurrencyResponseDTO — {Balance, + /// RoomKeyResponse} where Balance is the ROOM-currency balance row + /// (RecNet.Runtime/EDMKBBPGJAB.txt:203/222, member shape at + /// DPNLANKOHON.txt:331-414), matching what + /// RoomCurrenciesController already emits for the same row. + 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), + }; + + /// Grants a purchased StoreItems row. Wardrobe unlocks are a set, + /// so a multi-count press still yields one entry; everything else stacks in + /// PlayerInventory. + 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); + } + + /// Grants 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. + private async Task 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; + } + + /// Idempotently records a season-pass purchase. There is no + /// SeasonProgressEntity, so tier/elite state lives on ObjectiveProgress + /// under seasonTier:{storefrontType}:{purchasableItemId} and + /// seasonElite:{storefrontType} — the same table the season + /// objectives feed already uses. + 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(); + } } diff --git a/DorkNet.Server/Controllers/Clubs/ClubsController.cs b/DorkNet.Server/Controllers/Clubs/ClubsController.cs index a5f7c8c..7b10ae8 100644 --- a/DorkNet.Server/Controllers/Clubs/ClubsController.cs +++ b/DorkNet.Server/Controllers/Clubs/ClubsController.cs @@ -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 (PLILLKHMNDA): ClubId, Name, Description, -/// MainImageName, State, CreatorAccountId, Category, -/// Visibility, Joinability, AllowJuniors, MemberCount, -/// IsRRO, ClubhouseRoomId, ClubType -/// Announcement (NFEMLMAFFIP): AnnouncementId, +/// Club (2020 PLILLKHMNDA / 2023 FOIJDINBPFG): +/// ClubId, Name, Description, MainImageName, State, +/// CreatorAccountId, Category, Visibility, Joinability, +/// AllowJuniors, MemberCount, MinLevel, IsRRO, +/// ClubChatEnabled, ClubhouseRoomId, ClubType +/// Announcement (NFEMLMAFFIP / JDPPAFLFNBD): AnnouncementId, /// CreatorAccountId, ClubId, Title, Body, ImageName, /// CreatedAt, Meta -/// UnreadResponse (HPACLJHLHBG) — wraps per-club rows: -/// { clubId, LastReadAnnouncementId, announcements: [Announcement] } +/// UnreadResponse (EIKPFIDCKNE / NCHLBFPHFJE) — a LIST of +/// per-club rows { clubId, LastAnnouncementId, +/// LastReadAnnouncementId, announcements: [Announcement] } +/// ClubFeed (HPACLJHLHBG / FIAKMDGGIHH) — ONE such row, +/// not a list; see AnnouncementsForClub. /// CategoryTags plain List<String> (per /// JDJGIBLMFKK.GetPrimaryTags callback shape). /// [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)))); } /// GET /subscription/mine/member — list of clubs the @@ -204,6 +210,17 @@ public class ClubsController(ClubService clubs) : ControllerBase return Ok(playerSubs + clubSubs); } + /// GET /subscription/details/{accountId} — the creator's + /// subscription card. Both clients read the same three keys: the 2020 watch + /// via LNIKPLKOBDK (exact-match lowercase accountId, + /// clubId, subscriberCount) and 2023 via DDFNNBDLBKF + /// (tri-cased AccountId/ClubId/SubscriberCount, DDFNNBDLBKF.txt:279-346) + /// — camelCase satisfies both, so the casing here is load-bearing for 2020 + /// and must not be Pascal-ised. + /// + /// clubId used to be missing entirely. The 2023 caller is literally + /// GetCreatorClubIdForSubscription, so the field defaulted to 0 and + /// profile→creator-club navigation went nowhere. [HttpGet("/subscription/details/{accountId:long}")] public async Task 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); } - /// GET /club/{id}/permissions/{role} — 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. + /// GET /club/{id}/permissions/{role} — the permission + /// record for one role on this club. This used to answer a bare int; both + /// clients model it as an OBJECT (2023 MMOCDPPONNG, 2020 + /// JHEEFBMODPG — identical layout: long ClubId, membership-type + /// enum, six bools), so see . Neither + /// client actually issues the GET (both only PUT this template), but the + /// shapes now agree. [HttpGet("/club/{clubId:long}/permissions/{role:int}")] public async Task 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; } + + /// Repeated customTags form field sent by + /// club/{0}/modifydetails alongside visibility / joinability / + /// allowJuniors (IKMMOCKDKAF_NestedType_MAFIJOPDGHK.txt:139-185). + /// 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 — + /// FormOrJsonModelBinder.Convert 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 []. + public List? CustomTags { get; set; } } /// POST /club/{id}/modify + /modifydetails — @@ -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(); } } - /// POST /club/{id}/additionalimage/{slot} — 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. + /// POST/PUT /club/{id}/additionalimage/{slot} — the club + /// gallery's per-slot image setter (IKMMOCKDKAF.txt:16214). + /// + /// The request now binds (the client sends form-urlencoded + /// imageName, which [FromBody] used to reject with 415), but + /// the slot itself STILL is not persisted: there is no additional-image + /// table, and the envelope's AdditionalImages is the client's + /// HIKCHBLAMLP — {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 + /// ClubAdditionalImageEntity lands. [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 ───────────────────────────────── - /// GET /members/bulk?clubId=N&playerIds=A&playerIds=B - /// — bulk membership lookup. Wire shape is - /// List<JHMMGLNJHIB> with each entry carrying - /// AccountId + MembershipType. + /// GET/POST /members/bulk?id=A&id=B — bulk membership + /// lookup for nameplate club badges. Response rows are + /// FCKGOFHNDNJ: AccountId + MembershipType + /// (reader FMKALNIKMKF.txt). + /// + /// Two things the original handler got wrong, both visible at + /// IKMMOCKDKAF.txt:20976-21005: + /// * the account ids ride in repeated id params (:20997), not + /// playerIds; + /// * clubId 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, + /// cmp rdi,100 / cmovge edx,eax 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 clubId (2020 watch, admin tools) still narrows it. + /// [HttpGet("/members/bulk")] [HttpPost("/members/bulk")] public async Task 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 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()); - 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) ───────────── - /// GET /announcements/club/{clubId} — full - /// announcement feed for the club. Wire shape mirrors the unread - /// rollup (HPACLJHLHBG envelope) but holds every visible - /// announcement, newest first. + /// GET /announcements/club/{clubId} — full announcement + /// feed for one club, newest first. + /// + /// This returns a SINGLE envelope object, never an array: 2023 reads it as + /// FGLDKEJLAKB<FIAKMDGGIHH> + /// (IKMMOCKDKAF.txt:1170, route at :1452) and the 2020 watch as + /// IPromise<HPACLJHLHBG> (JDJGIBLMFKK.txt:1012,1167). + /// 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 [], not an object at + /// all. [HttpGet("/announcements/club/{clubId:long}")] public async Task 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(); + return Ok(AnnouncementEnvelope(clubId, rows, lastRead.GetValueOrDefault(clubId))); } /// GET /announcements/club/{clubId}/{aid} — 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) and AdditionalImages - // (List) 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(), + CustomTags = await CustomTagsAsync(clubId), AdditionalImages = Array.Empty(), - 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, }); } - /// - /// Bitmask of capabilities a given role has on a club. The bits - /// are arbitrary (the wire enum JHEEFBMODPG 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. - /// - private static int PermissionsForRole(int membershipType) => membershipType switch + /// The club's player-authored tags, for the envelope's + /// CustomTags (List<String> 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. + private Task> 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(); + + /// Replace a club's tag assignments wholesale — the client always + /// PUTs the complete customTags set, never a delta. + private async Task ReplaceCustomTagsAsync(long clubId, List 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(); + } + + /// + /// 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 + /// LCLFBBPEMIH.JNOKOILEFHD/OMMHPBFEHBH/IJKKPOBDJGG are + /// MMOCDPPONNG (recnet-runtime-decomp/LCLFBBPEMIH.cs:64-115) and the + /// 2020 watch's PIHMJGCGNLP uses the identically-laid-out + /// JHEEFBMODPG — 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 + /// BPHCOIBNCDP.txt:587-766: ClubId, Type (not + /// "MembershipType"), EditDetails, ApproveMember, CreateEvent, + /// PostAnnouncement, EditPermissionSettings, BanUnban. The same six names + /// are the form keys the client PUTs back on the permissions screen + /// (IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173-253). + /// + /// 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. + /// + 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 }; /// - /// Group a flat list of announcements into the per-club - /// HPACLJHLHBG envelope shape. Uses - /// for the outer object - /// because the wire mixes lowercase and PascalCase keys for the - /// same record (clubId + LastReadAnnouncementId + - /// announcements) 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 + /// List<NCHLBFPHFJE>, 2020 List<EIKPFIDCKNE>). /// private static List> GroupByClub( - List rows) + List rows, Dictionary lastReadByClub) { return rows .GroupBy(a => a.ClubId) - .Select(g => - { - var ordered = g.OrderByDescending(a => a.CreatedAt).ToList(); - return new Dictionary - { - ["clubId"] = g.Key, - ["LastReadAnnouncementId"] = (long?)null, - ["announcements"] = ordered.Select(ToWireAnnouncement).ToList(), - }; - }) + .Select(g => AnnouncementEnvelope(g.Key, g.ToList(), lastReadByClub.GetValueOrDefault(g.Key))) .ToList(); } + + /// + /// One club's announcement envelope. Uses + /// rather than an anonymous type + /// because the wire mixes casings inside a single record: the 2020 readers + /// match key names EXACTLY and want clubId + announcements + /// lowercase but LastAnnouncementId/LastReadAnnouncementId + /// PascalCase (EIKPFIDCKNE.txt, HPACLJHLHBG.txt:270-280). + /// The 2023 readers are tri-cased (CAKCKAMAAPP.txt:331-414, + /// AECJMONHBHM.txt:267-326) so camelCase satisfies them too. + /// + /// Two fixes over the original projection: + /// * LastAnnouncementId was omitted entirely though both readers + /// register it; + /// * LastReadAnnouncementId was an explicit JSON null into + /// a NON-nullable Int64 field on both clients. Always a number now. + /// + private static Dictionary AnnouncementEnvelope( + long clubId, List rows, long lastReadId) + { + var ordered = rows.OrderByDescending(a => a.CreatedAt).ThenByDescending(a => a.Id).ToList(); + return new Dictionary + { + ["clubId"] = clubId, + ["LastAnnouncementId"] = ordered.Count == 0 ? 0L : ordered.Max(a => a.Id), + ["LastReadAnnouncementId"] = lastReadId, + ["announcements"] = ordered.Select(ToWireAnnouncement).ToList(), + }; + } + + /// + /// Highest announcement id the caller has actually read, per club — the + /// LastReadAnnouncementId the envelopes above must carry. Done as + /// two index-supported reads plus an in-memory join for the same reason + /// avoids the LEFT JOIN: EF Core generates + /// pathological SQL for the anti-join on SQLite. + /// + private async Task> LastReadByClubAsync( + long playerId, IEnumerable clubIds) + { + var ids = clubIds.Distinct().ToList(); + if (ids.Count == 0) return new Dictionary(); + + var readIds = await db.ClubAnnouncementReads + .Where(r => r.PlayerId == playerId) + .Select(r => r.AnnouncementId) + .ToListAsync(); + if (readIds.Count == 0) return new Dictionary(); + + 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)); + } }