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