feat: Implement consumables support in StoreService and add related tests; include new consumables data

This commit is contained in:
Alexa
2026-07-08 01:33:29 +03:00
parent 8ede68b124
commit 16ae55444d
5 changed files with 610 additions and 0 deletions
@@ -61,6 +61,36 @@ public sealed class WatchShop2023Tests : IClassFixture<DorkNetServerFactory>
}
}
[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<string>();
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 });
+426
View File
@@ -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"
}
]
+2
View File
@@ -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` |
+89
View File
@@ -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 <c>consumable_desc</c> 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<Dictionary<string, ConsumableEntry>> _consumableCatalog =
new(LoadConsumableCatalog);
public static IReadOnlyDictionary<string, ConsumableEntry> ConsumableCatalog =>
_consumableCatalog.Value;
private static Dictionary<string, ConsumableEntry> 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<List<ConsumableEntry>>(fs,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List<ConsumableEntry>();
var bySlug = new Dictionary<string, ConsumableEntry>(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;
}
+63
View File
@@ -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.<apex>/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<player>_<guid>.bin"}`.
2. **Upload scene save** — same endpoint, `FileType=1` (RoomSave),
payload is the scene blob. Response:
`{"filename": "<blob>.dat"}` (e.g. `dorm_p<id>_v<N>.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.<apex>/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.<apex>` host not routed at the edge (Traefik `404 page not found`) — the save dies before reaching DorkNet. Probe `https://storage.<apex>/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`.