From 892c27ffea1565e3ae9531cd217cc7670105c84e Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:51:55 +0300 Subject: [PATCH] feat: Implement quest reward system with item selection and storage; update related services and database schema --- DorkNet.Server.Tests/RoomSave2023Tests.cs | 10 ++ DorkNet.Server.Tests/WatchShop2023Tests.cs | 28 ++-- .../API/Avatar/V2/AvatarGiftsController.cs | 4 +- .../API/GameRewards/GameRewardsController.cs | 101 ++++++++++---- .../API/Rooms/V2/RoomsController.cs | 27 ++++ .../Entities/GameRewardSelectionEntity.cs | 11 ++ DorkNet.Server/Data/quest_rewards.json | 4 + DorkNet.Server/Services/QuestRewardService.cs | 94 +++++++++++++ DorkNet.Server/Services/StoreService.cs | 126 +++++++++++++++--- DorkNet.Server/Startup/DatabaseBootstrap.cs | 25 ++++ .../Startup/ServiceCollectionExtensions.cs | 1 + docs/recroom-2023-room-consumables.md | 24 ++++ docs/recroom-2023-room-save.md | 26 ++++ 13 files changed, 416 insertions(+), 65 deletions(-) create mode 100644 DorkNet.Server/Data/quest_rewards.json create mode 100644 DorkNet.Server/Services/QuestRewardService.cs diff --git a/DorkNet.Server.Tests/RoomSave2023Tests.cs b/DorkNet.Server.Tests/RoomSave2023Tests.cs index 1fee3a9..9b0391e 100644 --- a/DorkNet.Server.Tests/RoomSave2023Tests.cs +++ b/DorkNet.Server.Tests/RoomSave2023Tests.cs @@ -76,6 +76,16 @@ public sealed class RoomSave2023Tests : IClassFixture using var json = JsonDocument.Parse(responseText); Assert.True(json.RootElement.GetProperty("success").GetBoolean(), responseText); + // 2023 commit-response contract (NEOPBOMGIOG): value must carry + // BOTH a Room and a SubRoomDataSave or the client's post-parse + // validator NREs and the watch shows "Failed to save room". + var value = json.RootElement.GetProperty("value"); + Assert.Equal(JsonValueKind.Object, value.GetProperty("Room").ValueKind); + var save = value.GetProperty("SubRoomDataSave"); + Assert.Equal(blobName, save.GetProperty("DataBlob").GetString()); + Assert.True(save.TryGetProperty("SubRoomDataSaveId", out _), responseText); + Assert.True(save.TryGetProperty("CreatedAt", out _), responseText); + await using (var scope = _factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); diff --git a/DorkNet.Server.Tests/WatchShop2023Tests.cs b/DorkNet.Server.Tests/WatchShop2023Tests.cs index 432c77b..3724793 100644 --- a/DorkNet.Server.Tests/WatchShop2023Tests.cs +++ b/DorkNet.Server.Tests/WatchShop2023Tests.cs @@ -68,27 +68,25 @@ public sealed class WatchShop2023Tests : IClassFixture 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(); + // Food / KO / camera consumables surface with the client's literal + // ConsumableItemDesc and no avatar item — that's what routes them to + // the Shop's Consumables tab and binds the baked prefab. (Hair dyes + // are a hybrid: they also carry an AvatarItemDesc colour guid, so + // they're excluded from the no-avatar-item check.) + var byDesc = new Dictionary(); 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); - } + if (desc.Length > 0) byDesc[desc] = gd; } - Assert.Contains("[FoodConsumable_RootBeer]", consumables); - Assert.Contains("[KOConsumable_Cola]", consumables); + foreach (var key in new[] { "[FoodConsumable_RootBeer]", "[KOConsumable_Cola]" }) + { + Assert.True(byDesc.TryGetValue(key, out var gd), $"missing consumable {key}"); + Assert.Equal("", gd.GetProperty("AvatarItemDesc").GetString()); + Assert.Equal(JsonValueKind.Null, gd.GetProperty("AvatarItemType").ValueKind); + } } private HttpClient ApiClient() diff --git a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs index 43bf2b5..fea9e3f 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs @@ -32,7 +32,9 @@ namespace DorkNet.Server.Controllers.API.Avatar.V2; public class AvatarGiftsController( DorkNetDbContext db, NotificationService notifications, - LevelService level) : ControllerBase + LevelService level, + QuestRewardService questRewards, + PlayerPresenceService presence) : ControllerBase { [HttpGet("api/avatar/v2/gifts")] [HttpGet("api/avatar/v3/gifts")] diff --git a/DorkNet.Server/Controllers/API/GameRewards/GameRewardsController.cs b/DorkNet.Server/Controllers/API/GameRewards/GameRewardsController.cs index 15c8b9e..a39ad8a 100644 --- a/DorkNet.Server/Controllers/API/GameRewards/GameRewardsController.cs +++ b/DorkNet.Server/Controllers/API/GameRewards/GameRewardsController.cs @@ -9,12 +9,31 @@ using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.GameRewards; +/// +/// api.rec.net/api/gamerewards/v1/* — the post-activity "choose 1 of 3" +/// item reward the client's RewardManager shows after a Stunt +/// Runner run (RewardType PostGameActivity=2) or an RRO quest +/// (PostQuestActivity=3). The client POSTs /request with a +/// rewardType + giftContext (no score/rank on the wire — the rank scales +/// the separate currency reward, which arrives via +/// storefronts/v2/balance as a rank-derived multiplier that +/// honours), then +/// polls /pending and picks one via /select. +/// +/// Each of GiftDrop1/2/3 is a real store item built through +/// — the SAME wire-verified +/// EFFIEFEFHHB shape the storefront uses (notably AvatarItemId is an +/// Int32), so the strict 2023 reader doesn't reject the selection. On +/// /select the chosen item is granted to inventory for free. +/// [ApiController] [Authorize] -public class GameRewardsController(DorkNetDbContext db, LevelService level) : ControllerBase +public class GameRewardsController(DorkNetDbContext db, LevelService level, StoreService store) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); + private const int OfferCount = 3; + [HttpGet("api/gamerewards/v1/pending")] public async Task Pending() { @@ -24,7 +43,8 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co .OrderBy(r => r.CreatedAt) .Take(20) .ToListAsync(); - return Ok(rows.Select(ToWire)); + var items = await LoadOfferedItemsAsync(rows); + return Ok(rows.Select(r => ToWire(r, items))); } [HttpPost("api/gamerewards/v1/request")] @@ -32,15 +52,27 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co public async Task RequestReward() { var req = await ReadRewardRequestAsync(); + var pid = Me; var row = new GameRewardSelectionEntity { - PlayerId = Me, + PlayerId = pid, RewardType = req.RewardType, GiftContext = req.GiftContext, - Message = string.IsNullOrWhiteSpace(req.Message) ? "Choose a reward" : req.Message[..Math.Min(req.Message.Length, 256)], + Message = string.IsNullOrWhiteSpace(req.Message) + ? "Choose a reward" + : req.Message[..Math.Min(req.Message.Length, 256)], }; db.GameRewardSelections.Add(row); await db.SaveChangesAsync(); + + // Pick the three offered items now that the row has an id (used as + // the deterministic seed) and persist them so /pending is stable + // and /select grants exactly what was shown. + var offered = await store.PickRewardItemsAsync(OfferCount, unchecked((int)row.Id)); + if (offered.Count > 0) row.Offer1ItemId = offered[0].Id; + if (offered.Count > 1) row.Offer2ItemId = offered[1].Id; + if (offered.Count > 2) row.Offer3ItemId = offered[2].Id; + await db.SaveChangesAsync(); return Ok(new { Success = true, Error = string.Empty }); } @@ -54,46 +86,61 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co .FirstOrDefaultAsync(r => r.Id == req.RewardSelectionId && r.PlayerId == pid && r.SelectedAt == null); if (row is null) return Ok(new { Success = false, Error = "reward_not_found" }); + // Map the client's chosen GiftDropId back to one of the offered + // store items. The GiftDropId is the item's PurchasableItemId. + var offeredIds = new[] { row.Offer1ItemId, row.Offer2ItemId, row.Offer3ItemId } + .Where(id => id > 0) + .ToList(); + var items = await db.StoreItems.Where(i => offeredIds.Contains(i.Id)).ToListAsync(); + var chosen = items.FirstOrDefault(i => StoreService.PurchasableItemIdFor(i) == req.GiftDropId) + ?? items.FirstOrDefault(); + row.SelectedAt = DateTime.UtcNow; row.SelectedGiftDropId = req.GiftDropId; + if (chosen is not null) + { + await store.GrantItemAsync(pid, chosen.Id); + row.GrantedItemId = chosen.Id; + } + // Small token + XP kicker on top of the item, matching the + // post-activity "you also banked a little" feel. await level.GrantCurrencyAsync(pid, 2, 25, $"gameReward:{row.Id}"); await level.AwardXpAsync(pid, 25, $"gameReward:{row.Id}"); await db.SaveChangesAsync(); return Ok(new { Success = true, Error = string.Empty }); } - private static object ToWire(GameRewardSelectionEntity row) => new + private async Task> LoadOfferedItemsAsync( + IEnumerable rows) + { + var ids = rows + .SelectMany(r => new[] { r.Offer1ItemId, r.Offer2ItemId, r.Offer3ItemId }) + .Where(id => id > 0) + .Distinct() + .ToList(); + if (ids.Count == 0) return new(); + return await db.StoreItems + .Where(i => ids.Contains(i.Id)) + .ToDictionaryAsync(i => i.Id); + } + + private static object ToWire(GameRewardSelectionEntity row, IReadOnlyDictionary items) => new { RewardSelectionId = row.Id, row.Message, row.GiftContext, row.RewardType, - GiftDrop1 = GiftDrop(row.Id * 10 + 1, "Tokens", currency: 25), - GiftDrop2 = GiftDrop(row.Id * 10 + 2, "XP", currency: 0), - GiftDrop3 = GiftDrop(row.Id * 10 + 3, "Bonus Tokens", currency: 25), + GiftDrop1 = OfferDrop(row.Offer1ItemId, items), + GiftDrop2 = OfferDrop(row.Offer2ItemId, items), + GiftDrop3 = OfferDrop(row.Offer3ItemId, items), row.CreatedAt, }; - private static object GiftDrop(long id, string name, int currency) => new + private static object? OfferDrop(long itemId, IReadOnlyDictionary items) { - GiftDropId = id, - FriendlyName = name, - Tooltip = string.Empty, - ConsumableItemDesc = string.Empty, - AvatarItemDesc = string.Empty, - AvatarItemType = 0, - EquipmentPrefabName = string.Empty, - EquipmentModificationGuid = string.Empty, - IsQuery = false, - Unique = false, - SubscribersOnly = false, - Rarity = 0, - CurrencyType = 2, - Currency = currency, - Context = 0, - ItemSetId = 0, - ItemSetFriendlyName = string.Empty, - }; + if (itemId <= 0 || !items.TryGetValue(itemId, out var item)) return null; + return StoreService.BuildGiftDrop(item, StoreService.PurchasableItemIdFor(item)); + } private async Task ReadRewardRequestAsync() { diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 2ece508..99584c5 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -705,6 +705,9 @@ public class RoomsController( SavedByAccountId = room.CreatorPlayerId, SavedOnPlatform = 7, SavedOnDeviceClass = 2, + CreatedAt = (scene?.DataModifiedAt ?? room.UpdatedAt) is var created && created != default + ? created.ToString("yyyy-MM-ddTHH:mm:ssZ") + : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), CreatedByAccountId = room.CreatorPlayerId, UnityAssetHash = string.Empty, UnityAsset = unityAsset, @@ -1074,6 +1077,30 @@ public class RoomsController( }; if (wrapCreateModifyResponse) { + // 2023 nested-JSON save commit: the response deserializer + // (NEOPBOMGIOG via KMBHAKAHNGH mapper) REQUIRES value = + // { Room: , SubRoomDataSave: } — both + // non-null, or its post-parse validator NREs and the watch + // reports "Failed to save room" even though the save + // persisted. Room reuses the GET rooms/{id} shape; the save + // reuses BuildStudioSaveWire (JKIFFPPAJNK keys per + // PELEHJLMKJO mapper: SubRoomDataSaveId/SubRoomId/ + // UnityAssetId/DataBlob/DataBlobHash/SavedByAccountId/ + // SavedOnPlatform/SavedOnDeviceClass/Description/CreatedAt). + if (body.SubRoomData is not null) + { + var subRoomId = (long)(sceneRow?.OrderIndex ?? (int)body.RoomSceneId); + var value = new + { + Room = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob), + SubRoomDataSave = BuildStudioSaveWire( + room, sceneRow, subRoomId, + sceneRow?.StudioSubRoomDataSaveId ?? 0L, + unityAssetTarget: null, unityAssetVersion: null), + }; + return Ok(new { success = true, value, error = string.Empty }); + } + var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob); return Ok(new { success = true, value = roomServerDetails, error = string.Empty }); } diff --git a/DorkNet.Server/Data/Entities/GameRewardSelectionEntity.cs b/DorkNet.Server/Data/Entities/GameRewardSelectionEntity.cs index 825cac3..99594ea 100644 --- a/DorkNet.Server/Data/Entities/GameRewardSelectionEntity.cs +++ b/DorkNet.Server/Data/Entities/GameRewardSelectionEntity.cs @@ -12,4 +12,15 @@ public class GameRewardSelectionEntity public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? SelectedAt { get; set; } public int? SelectedGiftDropId { get; set; } + + /// The three store-item ids offered as the choose-1-of-3 + /// reward. Persisted so re-fetching the pending reward is stable and + /// /select can grant exactly what was shown. 0 = empty slot. + public long Offer1ItemId { get; set; } + public long Offer2ItemId { get; set; } + public long Offer3ItemId { get; set; } + + /// Set once the chosen item has been granted to inventory, so + /// a replayed /select can't grant twice. + public long GrantedItemId { get; set; } } diff --git a/DorkNet.Server/Data/quest_rewards.json b/DorkNet.Server/Data/quest_rewards.json new file mode 100644 index 0000000..1909d35 --- /dev/null +++ b/DorkNet.Server/Data/quest_rewards.json @@ -0,0 +1,4 @@ +{ + "_comment": "Per-quest reward chest contents. Keyed by the RRO room id the player is in when they open the end-of-quest gift box (the gifts/generate request carries no quest id, so the server keys off the player's current room via presence). Each entry lists the store-item slugs that quest can award; the server picks one at random from the list. Slugs use the same scheme as the store: wardrobe-{guid} for outfits, consumable-{name} for consumables, equipment-{prefab}:{guid} for equipment. Populate `rooms` with real roomId -> item-slug lists.", + "rooms": {} +} diff --git a/DorkNet.Server/Services/QuestRewardService.cs b/DorkNet.Server/Services/QuestRewardService.cs new file mode 100644 index 0000000..709ac1a --- /dev/null +++ b/DorkNet.Server/Services/QuestRewardService.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Services; + +/// +/// Per-quest reward chest contents. The end-of-quest gift box +/// (RRO quest / Stunt Runner) is the existing gifts/generate + +/// gifts/consume flow: the client asks the server to generate a +/// gift, the box spawns showing it, and opening it grants the item. +/// +/// The generate request carries no quest id (only GiftContext / +/// IsGameGift / Message), so the quest is identified by the room the +/// player is in — . This +/// service maps that room id to the quest's item pool +/// (data/quest_rewards.json) and resolves one to a real store +/// item to award. When a room has no configured pool the caller falls +/// back to the generic random-wardrobe gift. +/// +public sealed class QuestRewardService(DorkNetDbContext db) +{ + private sealed class QuestRewardFile + { + public Dictionary> rooms { get; set; } = new(); + } + + private static readonly Lazy>> _map = new(Load); + + /// Room id → candidate item slugs for that quest's chest. + public static IReadOnlyDictionary> Map => _map.Value; + + public static bool HasRewardsForRoom(long roomId) => + Map.TryGetValue(roomId, out var slugs) && slugs.Count > 0; + + /// Resolve one active store item for the quest chest in + /// , chosen deterministically from + /// so repeated generate calls in the same + /// context are stable. Returns null when the room has no configured + /// pool or none of its slugs resolve to an active item. + public async Task PickForRoomAsync(long roomId, int seed) + { + if (!Map.TryGetValue(roomId, out var slugs) || slugs.Count == 0) return null; + + var items = await db.StoreItems + .Where(i => i.IsActive && slugs.Contains(i.Slug)) + .ToListAsync(); + if (items.Count == 0) return null; + + // Order deterministically by (id, seed) hash so the same room + + // seed always yields the same pick without persisting state. + return items + .OrderBy(i => HashCode.Combine(i.Id, seed)) + .First(); + } + + private static Dictionary> Load() + { + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "data", "quest_rewards.json"), + Path.Combine(AppContext.BaseDirectory, "Data", "quest_rewards.json"), + Path.Combine(Directory.GetCurrentDirectory(), "data", "quest_rewards.json"), + Path.Combine(Directory.GetCurrentDirectory(), "DorkNet.Server", "Data", "quest_rewards.json"), + }; + var path = candidates.FirstOrDefault(File.Exists); + if (path is null) return new(); + + try + { + using var fs = File.OpenRead(path); + var data = JsonSerializer.Deserialize(fs, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new QuestRewardFile(); + + var map = new Dictionary>(); + foreach (var (key, slugs) in data.rooms) + { + if (!long.TryParse(key, out var roomId)) continue; + var clean = (slugs ?? new()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (clean.Count > 0) map[roomId] = clean; + } + return map; + } + catch + { + return new(); + } + } +} diff --git a/DorkNet.Server/Services/StoreService.cs b/DorkNet.Server/Services/StoreService.cs index 3645f0c..0c3c738 100644 --- a/DorkNet.Server/Services/StoreService.cs +++ b/DorkNet.Server/Services/StoreService.cs @@ -882,6 +882,79 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio return new(true, null, newBalance, item.Slug); } + /// Grant a store item to a player WITHOUT charging currency — + /// the payout path for game rewards (RRO quest / Stunt Runner loot). + /// Mirrors 's inventory writes (wardrobe + /// GUID list for avatar items, stack list for everything else) but + /// skips the balance check + debit. Idempotent for unique avatar + /// items; stacks consumables. + public async Task GrantItemAsync(long playerId, long itemId) + { + var item = await GetByIdAsync(itemId); + if (item is null || !item.IsActive) + return new(false, "item_not_available", null, null); + + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == playerId); + if (avatar is null) + { + avatar = new AvatarEntity { PlayerId = playerId }; + db.Avatars.Add(avatar); + } + + if (TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc)) + { + var guid = InventoryAvatarItemDesc(avatarItemDesc); + var ownedGuids = ParseWardrobeInventory(avatar.InventoryJson); + if (!ownedGuids.Contains(guid, StringComparer.OrdinalIgnoreCase)) + { + ownedGuids.Add(guid); + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(ownedGuids); + avatar.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + return new(true, null, null, item.Slug); + } + + var inventory = ParseInventory(avatar.InventoryJson); + var existing = inventory.FirstOrDefault(e => e.ItemId == item.Slug); + var isConsumable = string.Equals(item.Category, "consumable", StringComparison.OrdinalIgnoreCase); + if (existing is not null) + { + if (!isConsumable) return new(true, "already_owned", null, item.Slug); + existing.Quantity += 1; + } + else + { + inventory.Add(new InventoryEntry { ItemId = item.Slug, Quantity = 1 }); + } + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(inventory); + avatar.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return new(true, null, null, item.Slug); + } + + /// Pick distinct renderable store + /// items to offer as a game-reward choice. Deterministic given the + /// seed (we key off the reward-selection id) so re-fetching the same + /// pending reward always offers the same items. + public async Task> PickRewardItemsAsync(int count, int seed) + { + var pool = await db.StoreItems + .Where(i => i.IsActive && + (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow)) + .ToListAsync(); + var renderable = pool.Where(IsRenderableGiftDrop).ToList(); + if (renderable.Count == 0) return new(); + + // Stable shuffle: order by a hash of (id, seed) so the same seed + // reproduces the same picks without persisting the offered set. + var picked = renderable + .OrderBy(i => HashCode.Combine(i.Id, seed)) + .Take(Math.Min(count, renderable.Count)) + .ToList(); + return picked; + } + private static List ParseWardrobeInventory(string? json) { if (string.IsNullOrWhiteSpace(json) || json == "[]") return new(); @@ -1012,7 +1085,8 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio { // PurchasableItemId is an int and must be unique within the // storefront — we use the StoreItem row id, capped to int range. - var itemId = (int)(i.Id & 0x7fffffff); + var itemId = PurchasableItemIdFor(i); + var giftDrop = BuildGiftDrop(i, itemId); var price = new { @@ -1020,6 +1094,32 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio Price = (int)i.Price, }; + return new + { + // Base PurchasableItem keys. + PurchasableItemId = itemId, + Type = 0, // PurchasableItemType.GiftDrop + IsFeatured = positionalIndex < 12, // first dozen show in Featured tab + Prices = new[] { price }, + SubscriberPrices = Array.Empty(), + // 2020 PurchasableGiftDrop carries a GiftDrops LIST; the 2023 + // row (POGBAGAHGIA formatter) reads a single "GiftDrop" object + // instead. Ship both: whichever formatter parses the row skips + // the key it doesn't know. + GiftDrop = giftDrop, + GiftDrops = new[] { giftDrop }, + }; + } + + /// Build one StorefrontGiftDrop (EFFIEFEFHHB) object for a + /// store row. Shared by the storefront rows and the game-reward + /// selection so both go through the same, wire-verified 2023 shape — + /// notably AvatarItemId is an Int32 (the descriptor + /// string lives in AvatarItemDesc); a string there makes the + /// strict reader throw "Malformed Response" for the whole + /// payload. + public static object BuildGiftDrop(StoreItemEntity i, int giftDropId) + { // Pull the AvatarItem GUID out of the slug for wardrobe-imported // entries. Hand-curated dorknet-* SKUs leave AvatarItemDesc // empty — they're cosmetic store tiles only. @@ -1048,10 +1148,10 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio consumableItemDesc = consumableHairDyeDesc; } - var giftDrop = new + return new { // REQUIRED. - GiftDropId = itemId, + GiftDropId = giftDropId, Rarity = MapRarity(i.Category), // 2023 additions (EMBCEDNHFLB formatter): TagList is a CSV // string, Currency is the CurrencyType enum repeated, ItemSet* @@ -1071,7 +1171,7 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio // +0x38). Shipping the {guid},,, descriptor here made the // strict Utf8Json reader throw "Malformed Response" on the // whole giftdropstore payload, so the Shop tab loaded nothing. - AvatarItemId = itemId, + AvatarItemId = giftDropId, AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType, ConsumableItemDesc = consumableItemDesc, EquipmentPrefabName = equipmentPrefabName, @@ -1084,24 +1184,6 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio Content = isHairDye ? GiftBoxContentHairDye : 0, EquipmentRarity = equipmentEntry?.rarity ?? 0, }; - - return new - { - // Base PurchasableItem keys. - PurchasableItemId = itemId, - Type = 0, // PurchasableItemType.GiftDrop - IsFeatured = positionalIndex < 12, // first dozen show in Featured tab - Prices = new[] { price }, - SubscriberPrices = Array.Empty(), - // 2020 PurchasableGiftDrop carries a GiftDrops LIST; the 2023 - // row (POGBAGAHGIA formatter) reads a single "GiftDrop" object - // instead. Ship both: whichever formatter parses the row skips - // the key it doesn't know. Missing "GiftDrop" leaves the 2023 - // row's inner desc null and StoreItemListModel's filter - // predicate NREs on every item — the Shop tab shows nothing. - GiftDrop = giftDrop, - GiftDrops = new[] { giftDrop }, - }; } /// The wire PurchasableItemId for a store row — must match diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 0350404..a0e81ab 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -109,6 +109,7 @@ public static class DatabaseBootstrap await EnsureRoomColumnsAsync(db); await EnsureRoomSceneColumnsAsync(db); await EnsureInventionColumnsAsync(db); + await EnsureGameRewardColumnsAsync(db); await EnsureMarch2023TablesAsync(db); // Coach system account at Player.Id=1. The RR-Original room seeder @@ -397,6 +398,30 @@ public static class DatabaseBootstrap catch { } } + /// Post-Initial columns on GameRewardSelections for the real + /// item-reward flow (offered store-item ids + granted marker). + private static async Task EnsureGameRewardColumnsAsync(DorkNetDbContext db) + { + var provider = db.Database.ProviderName ?? string.Empty; + if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase)) + { + foreach (var col in new[] { "Offer1ItemId", "Offer2ItemId", "Offer3ItemId", "GrantedItemId" }) + await db.Database.ExecuteSqlRawAsync( + $@"ALTER TABLE ""GameRewardSelections"" ADD COLUMN IF NOT EXISTS ""{col}"" bigint NOT NULL DEFAULT 0;"); + return; + } + + foreach (var col in new[] { "Offer1ItemId", "Offer2ItemId", "Offer3ItemId", "GrantedItemId" }) + { + try + { + await db.Database.ExecuteSqlRawAsync( + $@"ALTER TABLE ""GameRewardSelections"" ADD COLUMN ""{col}"" INTEGER NOT NULL DEFAULT 0;"); + } + catch { } + } + } + private static async Task EnsureRoomSceneColumnsAsync(DorkNetDbContext db) { var provider = db.Database.ProviderName ?? string.Empty; diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 6ff0705..aa9c9f7 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -201,6 +201,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Singleton — the protobuf blob is identical for every room and built // once at startup; no need to rebuild per request. diff --git a/docs/recroom-2023-room-consumables.md b/docs/recroom-2023-room-consumables.md index 16f86eb..1ece4c4 100644 --- a/docs/recroom-2023-room-consumables.md +++ b/docs/recroom-2023-room-consumables.md @@ -180,6 +180,30 @@ since 2020: Coverage: `DorkNet.Server.Tests/WatchShop2023Tests.cs`. +## Shop Consumables tab (food / potions / KO / camera film) + +The Shop's Consumables tab is fed by the same `giftdropstore` rows as +Clothing; a row is a consumable when its `GiftDrop.ConsumableItemDesc` +is set (bracketed literal item key, e.g. `[FoodConsumable_RootBeer]`) +and it carries no avatar item (`AvatarItemDesc` empty, `AvatarItemType` +null). Hair dyes are a hybrid — they set BOTH `ConsumableItemDesc` and +an `AvatarItemDesc` colour guid. + +The catalog of literal item keys lives in the client's baked +`ConsumableCollectionRuntimeConfig` MonoBehaviour (in `resources.assets`, +path_id 234695 for 2023.03.21). Each record is +`[ desc "[Family_Name]" ][ guid 22ch ][ display name ][ int family ] +[ tool desc ]`; families: CameraFilter, Food, KO, HandPotion, +HeadPotion, VFX, HairDye, Coupon. Extracted (minus hair dyes, coupons, +and promo-only bubbly variants) to `DorkNet.Server/Data/consumables.json` +and seeded as `Category="consumable"` store rows; +`StoreService.TryGetConsumableItemDesc` resolves a seeded slug to its +literal desc so it flows through the gift-drop builder like a hair dye. + +The exact per-item render gate (`INJHHPOOMKB.EEJKFKPAPAH`) dispatches +through a runtime service and could not be resolved statically — confirm +the tab actually populates in-game after seeding. + ## Related: unprefixed 2023 commerce probes The 2023 client also calls these commerce paths **without** the `api/` diff --git a/docs/recroom-2023-room-save.md b/docs/recroom-2023-room-save.md index 4d3fbd1..3c37549 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -34,6 +34,31 @@ on 2026-07-07. Diagnosed while fixing "Failed to save room" / `CurrentDataBlobName` (and the dorm-state row for dorms). `RoomData.Filename` is the FileType=6 metadata blob. + **Response contract** (deserializer `NEOPBOMGIOG`, mapper + `KMBHAKAHNGH`): legacy `{success, value, error}` envelope where + `value` MUST contain BOTH keys, non-null: + + ```json + { + "success": true, + "value": { + "Room": { …same DTO as GET rooms/{id} (BuildRoomServerDetails)… }, + "SubRoomDataSave": { "SubRoomDataSaveId": 0, "SubRoomId": 0, + "UnityAssetId": "", "DataBlob": ".dat", + "SavedByAccountId": 1, "SavedOnPlatform": 7, + "SavedOnDeviceClass": 2, "CreatedAt": "…", … } + }, + "error": "" + } + ``` + + The client's post-parse validator NREs if `Room` or + `SubRoomDataSave` is missing — the save persists server-side but the + watch shows "Failed to save room". `SubRoomDataSave` keys per the + `JKIFFPPAJNK` mapper (`PELEHJLMKJO`): SubRoomDataSaveId, SubRoomId, + UnityAssetId, DataBlob, DataBlobHash, SavedByAccountId, + SavedOnPlatform, SavedOnDeviceClass, Description, CreatedAt. + Server handling: `StorageController.Upload` (FileType 6 → `UploadGenericAsync("roommeta")`, CDN-servable) and `RoomsController.SubRoomData` → `ReadSaveRoomSceneRequestAsync` (nested @@ -45,6 +70,7 @@ Server handling: `StorageController.Upload` (FileType 6 → |---|---| | `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`. | +| `NDIKGKCFOCG: Failed to save room` preceded by a `NullReferenceException` at `NEOPBOMGIOG.FKDDCNLJOLF` | Commit returned 200 but `value` wasn't `{Room, SubRoomDataSave}` — the save actually persisted; only the response parse failed. | ## Related 2023 quirks fixed alongside