From 16ae55444d472e537c44a092850b118bc15f8cd3 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:33:29 +0300 Subject: [PATCH] feat: Implement consumables support in StoreService and add related tests; include new consumables data --- DorkNet.Server.Tests/WatchShop2023Tests.cs | 30 ++ DorkNet.Server/Data/consumables.json | 426 +++++++++++++++++++++ DorkNet.Server/Data/url-coverage.md | 2 + DorkNet.Server/Services/StoreService.cs | 89 +++++ docs/recroom-2023-room-save.md | 63 +++ 5 files changed, 610 insertions(+) create mode 100644 DorkNet.Server/Data/consumables.json create mode 100644 docs/recroom-2023-room-save.md diff --git a/DorkNet.Server.Tests/WatchShop2023Tests.cs b/DorkNet.Server.Tests/WatchShop2023Tests.cs index 0ac264d..432c77b 100644 --- a/DorkNet.Server.Tests/WatchShop2023Tests.cs +++ b/DorkNet.Server.Tests/WatchShop2023Tests.cs @@ -61,6 +61,36 @@ public sealed class WatchShop2023Tests : IClassFixture } } + [Fact] + public async Task Storefront_includes_food_consumables_with_literal_desc() + { + using var client = ApiClient(); + var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3"); + var rows = storefront.GetProperty("StoreItems"); + + // Every consumable tile carries a bracketed ConsumableItemDesc (the + // client's literal item key) and no avatar item — that's what routes + // it to the Shop's Consumables tab and binds the baked prefab. + var consumables = new List(); + foreach (var row in rows.EnumerateArray()) + { + var gd = row.GetProperty("GiftDrop"); + var desc = gd.GetProperty("ConsumableItemDesc").GetString() ?? ""; + if (desc.StartsWith("[") || desc.StartsWith("(")) + { + consumables.Add(desc); + // Consumables are not avatar items: AvatarItemDesc empty and + // AvatarItemType null (a non-null enum here would tab it as + // clothing). + Assert.Equal("", gd.GetProperty("AvatarItemDesc").GetString()); + Assert.Equal(JsonValueKind.Null, gd.GetProperty("AvatarItemType").ValueKind); + } + } + + Assert.Contains("[FoodConsumable_RootBeer]", consumables); + Assert.Contains("[KOConsumable_Cola]", consumables); + } + private HttpClient ApiClient() { var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); diff --git a/DorkNet.Server/Data/consumables.json b/DorkNet.Server/Data/consumables.json new file mode 100644 index 0000000..749b2f6 --- /dev/null +++ b/DorkNet.Server/Data/consumables.json @@ -0,0 +1,426 @@ +[ + { + "slug": "consumable-camerafilterconsumable-bw", + "name": "Black & White Camera Film", + "consumable_desc": "[CameraFilterConsumable_BW]", + "guid": "frOMH6WxDEG1fBqC4_83vg", + "family": "CameraFilter", + "family_label": "Camera Film" + }, + { + "slug": "consumable-camerafilterconsumable-dawn", + "name": "Dawn Camera Film", + "consumable_desc": "[CameraFilterConsumable_Dawn]", + "guid": "m0bVLwWGj0GuIBSb6wCk6Q", + "family": "CameraFilter", + "family_label": "Camera Film" + }, + { + "slug": "consumable-camerafilterconsumable-dracula", + "name": "Dracula Film", + "consumable_desc": "[CameraFilterConsumable_Dracula]", + "guid": "oG7CdvW7p0-S8sVe9w5vRw", + "family": "CameraFilter", + "family_label": "Camera Film" + }, + { + "slug": "consumable-camerafilterconsumable-ghostbeard", + "name": "Ghost Beard Film", + "consumable_desc": "[CameraFilterConsumable_GhostBeard]", + "guid": "1c0Djlp090uBDczEobYNQw", + "family": "CameraFilter", + "family_label": "Camera Film" + }, + { + "slug": "consumable-camerafilterconsumable-goblinking", + "name": "Goblin King Film", + "consumable_desc": "[CameraFilterConsumable_GoblinKing]", + "guid": "6SyCoJCgo0Wd6qlPlnMOtg", + "family": "CameraFilter", + "family_label": "Camera Film" + }, + { + "slug": "consumable-camerafilterconsumable-jumbotron", + "name": "Jumbotron Film", + "consumable_desc": "[CameraFilterConsumable_Jumbotron]", + "guid": "Il4VmrnjDkqjmjzddqoIEw", + "family": "CameraFilter", + "family_label": "Camera Film" + }, + { + "slug": "consumable-camerafilterconsumable-sepia", + "name": "Sepia Camera Film", + "consumable_desc": "[CameraFilterConsumable_Sepia]", + "guid": "A5M-yf9tgUihq1uab3v58g", + "family": "CameraFilter", + "family_label": "Camera Film" + }, + { + "slug": "consumable-camerafilterconsumable-witch", + "name": "Witch Film", + "consumable_desc": "[CameraFilterConsumable_Witch]", + "guid": "mL3zCEuy2UWZxAV4V-OsMA", + "family": "CameraFilter", + "family_label": "Camera Film" + }, + { + "slug": "consumable-handpotionconsumable-goblin1", + "name": "High Five O'Gold", + "consumable_desc": "[HandPotionConsumable_Goblin1]", + "guid": "-hy0qD-iUk-v4NHxNzanmg", + "family": "HandPotion", + "family_label": "Hand Potion" + }, + { + "slug": "consumable-handpotionconsumable-pirate1", + "name": "High Five O'Gunpowder", + "consumable_desc": "[HandPotionConsumable_Pirate1]", + "guid": "YEfbJTnsR0yT_p7e7tb_kQ", + "family": "HandPotion", + "family_label": "Hand Potion" + }, + { + "slug": "consumable-handpotionconsumable-scifi1", + "name": "High Five O'Lasers", + "consumable_desc": "[HandPotionConsumable_SciFi1]", + "guid": "xHOwjwpXd0GDkvBz2VqieA", + "family": "HandPotion", + "family_label": "Hand Potion" + }, + { + "slug": "consumable-handpotionconsumable-goblin2", + "name": "High Five O'Spells", + "consumable_desc": "[HandPotionConsumable_Goblin2]", + "guid": "VQSgL2pTLkWx4B3kwYG7UA", + "family": "HandPotion", + "family_label": "Hand Potion" + }, + { + "slug": "consumable-handpotionconsumable-dracula1", + "name": "High Five O'Whip", + "consumable_desc": "[HandPotionConsumable_Dracula1]", + "guid": "lag2tZyB90W04lQ7ol4vMw", + "family": "HandPotion", + "family_label": "Hand Potion" + }, + { + "slug": "consumable-foodconsumable-87flavorcake", + "name": "87 Flavor Cake", + "consumable_desc": "[FoodConsumable_87FlavorCake]", + "guid": "xwQWBB_fekmTqRc2LB92cg", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-donut-assorted", + "name": "Assorted Donuts", + "consumable_desc": "[FoodConsumable_Donut_Assorted]", + "guid": "ZuvkidodzkuOfGLDnTOFyg", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-bubbletea", + "name": "Bubble Tea", + "consumable_desc": "[FoodConsumable_BubbleTea]", + "guid": "JxAl1wVJ1UGAjoT3m3ilug", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-bubbly", + "name": "Bubbly", + "consumable_desc": "[FoodConsumable_Bubbly]", + "guid": "iiGTvhOCHkOTNJhb16Zbyw", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-cakepops", + "name": "Cake Pops", + "consumable_desc": "[FoodConsumable_CakePops]", + "guid": "Efd3_dy4ZEqWr7i6j5x0aA", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-candyapple", + "name": "Candy Apples", + "consumable_desc": "[FoodConsumable_CandyApple]", + "guid": "EmPvh3I6L0uK_1i8Wy_ylQ", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-yellowcake", + "name": "Celebration Cake", + "consumable_desc": "[FoodConsumable_YellowCake]", + "guid": "Sk_1sm88ZU2zpWn01Lv7hw", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-cheesepizza", + "name": "Cheese Pizza", + "consumable_desc": "[FoodConsumable_CheesePizza]", + "guid": "5hIAZ9wg5EyG1cILf4FS2A", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-chocolatecake", + "name": "Chocolate Cake", + "consumable_desc": "[FoodConsumable_ChocolateCake]", + "guid": "BbJb1_0g4EGJP_CeNrjpew", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-donut-frosted", + "name": "Chocolate Frosted Donuts", + "consumable_desc": "[FoodConsumable_Donut_Frosted]", + "guid": "mMCGPgK3tki5S_15q2Z81A", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-popcorn-chocolate", + "name": "Chocolate Popcorn", + "consumable_desc": "[FoodConsumable_Popcorn_Chocolate]", + "guid": "lWN_g4OcN0S0C4n6cSZq4g", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-chocolatevariety", + "name": "Chocolate Variety Box", + "consumable_desc": "[FoodConsumable_ChocolateVariety]", + "guid": "K9RbXo-4U0q6NbGu8VL1Sw", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-popcorn-flaming", + "name": "Flaming Popcorn", + "consumable_desc": "[FoodConsumable_Popcorn_Flaming]", + "guid": "uswFSANaZES0EJMLfOgNSw", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-donut-glazed", + "name": "Glazed Donuts", + "consumable_desc": "[FoodConsumable_Donut_Glazed]", + "guid": "7OZ5AE3uuUyqa0P-2W1ptg", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-hawaiianpizza", + "name": "Hawaiian Pizza", + "consumable_desc": "[FoodConsumable_HawaiianPizza]", + "guid": "_jnjYGBcyEWY5Ub4OezXcA", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-donut-invasion", + "name": "Invasion Donuts", + "consumable_desc": "(FoodConsumable_Donut_Invasion)", + "guid": "-_VNAj3P10aWCCJZoVTF4Q", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-latte", + "name": "Latte", + "consumable_desc": "[FoodConsumable_Latte]", + "guid": "P15H1ONBhk-5DYYjid1ttg", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-pepperonipizza", + "name": "Pepperoni Pizza", + "consumable_desc": "[FoodConsumable_PepperoniPizza]", + "guid": "mq23W-RSP0G8iGNLdrcpUw", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-popcorn-butter", + "name": "Popcorn", + "consumable_desc": "[FoodConsumable_Popcorn_Butter]", + "guid": "QRx0aSTT9keMFdAJMQHdTg", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-redapples", + "name": "Red Apples", + "consumable_desc": "[FoodConsumable_RedApples]", + "guid": "uMHrUPLYFk2rJOW_uop5Aw", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-redvelvetcake", + "name": "Red Velvet Cake", + "consumable_desc": "[FoodConsumable_RedVelvetCake]", + "guid": "LwaotjVEBUir0-w8126n_g", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-rootbeer", + "name": "Root Beer", + "consumable_desc": "[FoodConsumable_RootBeer]", + "guid": "JfnVXFmilU6ysv-VbTAe3A", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-pretzel-salted", + "name": "Salted Pretzels", + "consumable_desc": "[FoodConsumable_Pretzel_Salted]", + "guid": "InQ25wQMGkG_bvuD5rf2Ag", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-donut-snowman", + "name": "Snowman Donuts", + "consumable_desc": "(FoodConsumable_Donut_Snowman)", + "guid": "jdrRJ7deWk2P9aRRrPy3zw", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-supremepizza", + "name": "Supreme Pizza", + "consumable_desc": "[FoodConsumable_SupremePizza]", + "guid": "wUCIKdJSvEmiQHYMyx4X4w", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-sushi", + "name": "Sushi", + "consumable_desc": "[FoodConsumable_Sushi]", + "guid": "4My42w1D1Uu3-98pXB-x_Q", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-foodconsumable-vrcola", + "name": "VR Cola", + "consumable_desc": "[FoodConsumable_VRCola]", + "guid": "YpL6eN6sKEquOYIYnbhvew", + "family": "Food", + "family_label": "Food" + }, + { + "slug": "consumable-koconsumable-bearclaw", + "name": "KO - Bear Claw", + "consumable_desc": "[KOConsumable_BearClaw]", + "guid": "ZtZnYBpKkECJlhHmkj4MiA", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-koconsumable-bees", + "name": "KO - Bees", + "consumable_desc": "[KOConsumable_Bees]", + "guid": "g3kxdlJv5kO8PuBXveM48w", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-koconsumable-cola", + "name": "KO - Cola", + "consumable_desc": "[KOConsumable_Cola]", + "guid": "wx--2TPTdEuGAqLCjs9Qag", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-koconsumable-fangs", + "name": "KO - Fangs", + "consumable_desc": "[KOConsumable_Fangs]", + "guid": "XOZcxx-Klkyhe-MDbTqiwA", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-koconsumable-fryingpan", + "name": "KO - FryingPan", + "consumable_desc": "[KOConsumable_FryingPan]", + "guid": "uGVFydNSokCXFAmXu3aceQ", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-koconsumable-grenade", + "name": "KO - Grenade", + "consumable_desc": "[KOConsumable_Grenade]", + "guid": "EAhk3ZZdXEmH5wRAXXT24Q", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-koconsumable-ranger", + "name": "KO - Ranger", + "consumable_desc": "[KOConsumable_Ranger]", + "guid": "Av-wvjXvvkmNVSz7ZZnTiA", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-koconsumable-shield", + "name": "KO - Shield", + "consumable_desc": "[KOConsumable_Shield]", + "guid": "5AJin8T2iEG7BzOPOgx2HA", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-koconsumable-skullwings", + "name": "KO - Skull Wings", + "consumable_desc": "[KOConsumable_SkullWings]", + "guid": "U38Qe6rhEk6mFvArHfYjng", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-koconsumable-starpower", + "name": "KO - Star Power", + "consumable_desc": "[KOConsumable_StarPower]", + "guid": "yvqSbK2czkS2sUCRdrGaEw", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-koconsumable-tiretrack", + "name": "KO - Tire Track", + "consumable_desc": "[KOConsumable_TireTrack]", + "guid": "J1WqFNUWo0OBi4LGKPDHWw", + "family": "KO", + "family_label": "KO Effect" + }, + { + "slug": "consumable-vfxconsumable-sparklerbox", + "name": "Sparklers", + "consumable_desc": "[VFXConsumable_SparklerBox]", + "guid": "hRdK6NGfAkGBmYSs_xGzXg", + "family": "VFX", + "family_label": "VFX" + }, + { + "slug": "consumable-headpotionconsumable-dkmode", + "name": "Ego Potion", + "consumable_desc": "[HeadPotionConsumable_DKMode]", + "guid": "Tpxqe_lycUelySRHM8B0Vw", + "family": "HeadPotion", + "family_label": "Potion" + } +] \ No newline at end of file diff --git a/DorkNet.Server/Data/url-coverage.md b/DorkNet.Server/Data/url-coverage.md index 26c13f7..89ab964 100644 --- a/DorkNet.Server/Data/url-coverage.md +++ b/DorkNet.Server/Data/url-coverage.md @@ -306,8 +306,10 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`, | GET | `rooms/{roomId}/subrooms` | REAL | `Rooms.V2.RoomsController.SubRooms` — Studio/2023 subroom list (one synthesised "Home" for single-scene rooms; `RoomScenes` rows otherwise) | | GET | `rooms/{roomId}/subrooms/{subRoomId}` | REAL | `Rooms.V2.RoomsController.SubRoom` — one subroom by `RoomScene.OrderIndex` | | GET | `rooms/{roomId}/subrooms/{subRoomId}/data` | REAL | `Rooms.V2.RoomsController.SubRoomData` — current `SubRoomDataSave` for the subroom | +| POST | `rooms/{roomId}/subrooms/{subRoomId}/data` | REAL | `Rooms.V2.RoomsController.SubRoomData` — 2023 save commit. JSON body `{UnityAssetId, RoomData:{Filename,Hash,OwnershipProof}, SubRoomData:{Filename,…}}`; `SubRoomData.Filename` (the FileType=1 `/upload` result) becomes `CurrentDataBlobName`. 2020 form-urlencoded variant unchanged. See `docs/recroom-2023-room-save.md` | | GET | `rooms/{roomId}/subrooms/{subRoomId}/saves` | REAL | `Rooms.V2.RoomsController.SubRoomSaves` — current save + historical blobs, newest first | | GET | `rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}` | REAL | `Rooms.V2.RoomsController.StudioSubRoomSave` — Studio save metadata + baked `unity_assets` bundle list the 2023 client downloads | +| GET | `rooms/search_rooms/{query}&skip={n}&take={n}` | REAL | `Rooms.V2.RoomsController.SearchRoomsOnly` — 2023 play-menu search (`IBEOONPEELF.SearchRooms`); paging pairs are embedded in the path segment, parsed by `ParsePagedPathSegment`. Also bare + `roomserver/` aliases; same pattern on `rooms/hot_rooms`, `rooms/hot_roomsandplaylists`, `rooms/search_roomsandplaylists` | | POST | `api/rooms/v2/report` | REAL | `RoomsModerationController.Report` — files a room report | | GET | `api/rooms/v1/modrooms` | REAL | `Rooms.V2.RoomsController.MyModerated` (route alias `api/rooms/v1/modrooms`) — rooms the caller moderates | | GET | `api/rooms/v2/myrooms` | REAL | `Rooms.V2.RoomsController.MyRooms` | diff --git a/DorkNet.Server/Services/StoreService.cs b/DorkNet.Server/Services/StoreService.cs index e337859..3645f0c 100644 --- a/DorkNet.Server/Services/StoreService.cs +++ b/DorkNet.Server/Services/StoreService.cs @@ -272,9 +272,89 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio added = true; } + // Food / KO / camera-film / potion consumables from the client's + // baked ConsumableCollectionRuntimeConfig (data/consumables.json). + // These populate the watch Shop's Consumables tab; each carries the + // client's literal ConsumableItemDesc ("[FoodConsumable_RootBeer]") + // so the tile binds to the right baked consumable prefab. + foreach (var c in ConsumableCatalog.Values) + { + if (existingSlugs.Contains(c.slug)) continue; + db.StoreItems.Add(new StoreItemEntity + { + Slug = c.slug, + DisplayName = c.name, + Description = $"{c.family_label} consumable.", + Category = "consumable", + Price = StoreItemPrice, + CurrencyType = 2, + ImageName = string.Empty, + Storefront = "main", + IsActive = true, + IsLimitedTime = false, + }); + existingSlugs.Add(c.slug); + added = true; + } + if (added) await db.SaveChangesAsync(); } + // ── Consumables (food / potions / KO / camera film) ──────────────── + // Source: data/consumables.json, extracted offline from the client's + // ConsumableCollectionRuntimeConfig MonoBehaviour (resources.assets). + // The consumable_desc is the client's literal item key, fed + // verbatim into the gift-drop's ConsumableItemDesc so the Shop tile + // resolves to the baked consumable — same mechanism as hair dyes. + public sealed class ConsumableEntry + { + public string slug { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; + public string consumable_desc { get; set; } = string.Empty; + public string guid { get; set; } = string.Empty; + public string family { get; set; } = string.Empty; + public string family_label { get; set; } = "Consumable"; + } + + private static readonly Lazy> _consumableCatalog = + new(LoadConsumableCatalog); + + public static IReadOnlyDictionary ConsumableCatalog => + _consumableCatalog.Value; + + private static Dictionary LoadConsumableCatalog() + { + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "data", "consumables.json"), + Path.Combine(AppContext.BaseDirectory, "Data", "consumables.json"), + Path.Combine(Directory.GetCurrentDirectory(), "data", "consumables.json"), + Path.Combine(Directory.GetCurrentDirectory(), "DorkNet.Server", "Data", "consumables.json"), + }; + var path = candidates.FirstOrDefault(File.Exists); + if (path is null) return new(); + + try + { + using var fs = File.OpenRead(path); + var rows = System.Text.Json.JsonSerializer.Deserialize>(fs, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new List(); + var bySlug = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in rows) + { + if (string.IsNullOrWhiteSpace(row.slug) || string.IsNullOrWhiteSpace(row.consumable_desc)) + continue; + bySlug[row.slug] = row; + } + return bySlug; + } + catch + { + return new(); + } + } + // ── Equipment skins ──────────────────────────────────────────── // Source: data/equipment_skins.json. The old client does not infer // equipment reward payloads from a store slug; it expects the exact @@ -1091,6 +1171,15 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio if (TryGetHairDyePayload(slug, out consumableItemDesc, out _)) return true; + // Food / KO / camera / potion consumables — the wire desc is the + // client's literal item key from data/consumables.json. + if (ConsumableCatalog.TryGetValue(slug, out var entry) + && !string.IsNullOrWhiteSpace(entry.consumable_desc)) + { + consumableItemDesc = entry.consumable_desc; + return true; + } + return false; } diff --git a/docs/recroom-2023-room-save.md b/docs/recroom-2023-room-save.md new file mode 100644 index 0000000..4d3fbd1 --- /dev/null +++ b/docs/recroom-2023-room-save.md @@ -0,0 +1,63 @@ +# 2023 client room-save flow + +Wire contract for the 2023.03.21 client's room save, reverse-engineered +from `OGPDOMCNIFM.UploadRoomDataBlobAndSyncReload` +(RecRoom.RoomLoading.Runtime) and verified against dev server traffic +on 2026-07-07. Diagnosed while fixing "Failed to save room" / +"An error occurred" save failures. + +## Sequence + +1. **Upload room metadata** — `POST https://storage./upload`, + multipart (`File`=file.bin, `FileType=6`, optional `References`). + FileType **6 = RoomMetadata** is new in 2023 (2020 enum stopped at + 5=Invention). Small payload (~4 bytes observed for a dorm). Response: + `{"filename": "roommeta_p_.bin"}`. +2. **Upload scene save** — same endpoint, `FileType=1` (RoomSave), + payload is the scene blob. Response: + `{"filename": ".dat"}` (e.g. `dorm_p_v.dat`). + The upload response DTO is `{Filename, Hash, OwnershipProof}` — the + client accepts both `Filename`/`filename` casings; `Hash` and + `OwnershipProof` may be null. +3. **Commit** — `POST https://rooms./rooms/{roomId}/subrooms/{subRoomId}/data` + with JSON: + + ```json + { + "UnityAssetId": null, + "RoomData": { "Filename": "roommeta_….bin", "Hash": null, "OwnershipProof": null }, + "SubRoomData": { "Filename": "dorm_p…_vN.dat", "Hash": null, "OwnershipProof": null } + } + ``` + + `SubRoomData.Filename` is the scene save and must become the room's + `CurrentDataBlobName` (and the dorm-state row for dorms). + `RoomData.Filename` is the FileType=6 metadata blob. + +Server handling: `StorageController.Upload` (FileType 6 → +`UploadGenericAsync("roommeta")`, CDN-servable) and +`RoomsController.SubRoomData` → `ReadSaveRoomSceneRequestAsync` (nested +2023 JSON via `BlobRefDto`) → `SaveDataCore`. + +## Failure modes seen + +| Symptom (client) | Cause | +|---|---| +| `NDIKGKCFOCG: An error occurred` at `UploadRoomDataBlobAndSyncReload`, upload frame (`KPLOPGMJOLE`) in stack | `storage.` host not routed at the edge (Traefik `404 page not found`) — the save dies before reaching DorkNet. Probe `https://storage./healthz`. | +| `NDIKGKCFOCG: Failed to save room`, no upload frame in stack | Commit POST rejected — historically 400 `missing_room_data_filename` because the server parsed only the flat 2020 body and missed `SubRoomData.Filename`. | + +## Related 2023 quirks fixed alongside + +- **Play-menu search** (`IBEOONPEELF.SearchRooms`): calls + `rooms/search_rooms/{query}&skip={n}&take={n}` — `rooms/`-prefixed + AND paging embedded in the final path segment. Same segment style on + `hot_rooms`, `hot_roomsandplaylists`, `search_roomsandplaylists` + (parsed by `RoomsController.ParsePagedPathSegment`). +- **Room photo gallery** (`KLJOGJHBONK`, "Could not show images for + room"): `api/images/v{2-5}/room/{id}?sort=CheerCount_Desc&filter=PublicOnly` + — `sort`/`filter` arrive as enum NAMES; binding them as `int` makes + `[ApiController]` auto-400. Bound as strings, mapped in + `ImagesController.ParseSort`. + +Tests: `DorkNet.Server.Tests/RoomSave2023Tests.cs`, +`DorkNet.Server.Tests/RoomBrowse2023Tests.cs`.