using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using DorkNet.Server.Controllers.API.Avatar.V4; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; namespace DorkNet.Server.Services; /// /// Owns the in-game store catalog, the storefronts API shaping, and /// the purchase flow (verify balance → deduct currency → grant item to /// inventory). The watch's "Shop" tab fetches this via /// GET api/storefronts/v3/all and friends; the purchase flow /// runs through POST api/purchase/v1/{initiate|process|complete}. /// /// Currency: items are priced in tokens (CurrencyType=2), the same /// currency the level-up + cheer-receive flow grants. Real-money /// purchases (CurrencyType=1) are intentionally not wired up — this is /// a private server. /// public class StoreService(DorkNetDbContext db, LevelService level, IConfiguration config) { public const long StoreItemPrice = 1; public const string AllStorefrontKey = "all"; public const string RroStorefrontKey = "rro"; private const int StoreAvatarItemTypeOutfit = 0; private const int StoreAvatarItemTypeHairDye = 1; private const int GiftBoxContentHairDye = 6; private const string WardrobePrefix = "wardrobe-"; public const string EquipmentSkinPrefix = "equipment-skin:"; public sealed record StorefrontDefinition( string Key, int? StorefrontType, string DisplayName, string Scope); private static readonly StorefrontDefinition[] StorefrontDefinitions = { new("main", null, "Main watch catalog", "watch"), new("watch", 3, "Watch gift-drop shelf", "watch"), new(AllStorefrontKey, null, "All storefront shelves", "shared"), new(RroStorefrontKey, null, "All RRO and Rec Center shelves", "shared"), new("season:1", null, "Season 1", "season"), new("giftdrop:1", 1, "Laser Tag", "room"), new("giftdrop:2", 2, "Rec Center", "room"), new("giftdrop:100", 100, "Quest - Lost Skulls", "room"), new("giftdrop:101", 101, "Quest - Dracula", "room"), new("giftdrop:102", 102, "Quest - Golden Trophy", "room"), new("giftdrop:103", 103, "Quest - Crimson Cauldron", "room"), new("giftdrop:200", 200, "Rec Royale", "room"), new("giftdrop:300", 300, "Cafe", "room"), new("giftdrop:400", 400, "Paintball", "room"), new("giftdrop:401", 401, "Paintball - River", "room"), new("giftdrop:402", 402, "Paintball - Homestead", "room"), new("giftdrop:403", 403, "Paintball - Quarry", "room"), new("giftdrop:404", 404, "Paintball - Clear Cut", "room"), new("giftdrop:405", 405, "Paintball - Spillway", "room"), new("giftdrop:406", 406, "Paintball - Sunset Drive-In", "room"), new("giftdrop:500", 500, "Bowling", "room"), new("giftdrop:600", 600, "Stunt Runner", "room"), new("giftdrop:700", 700, "Dorm Mirror", "room"), }; public static IReadOnlyList GetStorefrontDefinitions() => StorefrontDefinitions; public static string StorefrontKeyForType(int storefrontType) => storefrontType == 3 ? "watch" : $"giftdrop:{storefrontType}"; private static readonly (string Slug, string Name, string ConsumableDesc, string HairColorGuid)[] PermanentHairDyes = { ("hairdye-red", "Permanent KREQ Red Hair Dye", "[hairdyepotionconsumable_red]", "xyHGGFKNWEOTnXloDFahPQ"), ("hairdye-dark-red", "Permanent Cauldron Crimson Hair Dye", "[hairdyepotionconsumable_darkred]", "XCwNFMT1i0i9Q9gJYTLPUQ"), ("hairdye-orange", "Permanent Bucket Orange Hair Dye", "[hairdyepotionconsumable_orange]", "1I8AD333gku-iOQlS6WWlw"), ("hairdye-yellow", "Permanent Pirate Gold Hair Dye", "[hairdyepotionconsumable_yellow]", "pQNfh-3DsEGWfiIls6Qf6g"), ("hairdye-green", "Permanent Goblin Green Hair Dye", "[hairdyepotionconsumable_green]", "TylV8M6zjUaadhxHPWAS-w"), ("hairdye-teal", "Permanent EDM Emerald Hair Dye", "[hairdyepotionconsumable_teal]", "wXJZbYCkZE-NMbPVf2YuFA"), ("hairdye-cyan", "Permanent H2Oasis Blue Hair Dye", "[hairdyepotionconsumable_cyan]", "Bjsgo9ddeUCk3zW1-DdJbw"), ("hairdye-blue", "Permanent Butterfly Blue Hair Dye", "[hairdyepotionconsumable_blue]", "yTdx1nVVtECU8Vh7EC8dGA"), ("hairdye-dark-blue", "Permanent Ranger Roy-Al Blue Hair Dye", "[hairdyepotionconsumable_darkblue]", "3ksu_FLaukeJ-YE90jb4Ow"), ("hairdye-purple", "Permanent Propulsion Purple Hair Dye", "[hairdyepotionconsumable_purple]", "53y1prYATU2drZDS-jpaqg"), ("hairdye-dark-purple", "Permanent Gizmo Grape Hair Dye", "[hairdyepotionconsumable_darkpurple]", "QmgjYhH_L0q58eF5SWeDhQ"), ("hairdye-pink", "Permanent Mousebot Magenta Hair Dye", "[hairdyepotionconsumable_pink]", "8k_gZA_lrEyJxI19GrcAZg"), ("hairdye-dark-pink", "Permanent Recreational Raspberry Hair Dye", "[hairdyepotionconsumable_darkpink]", "yejur5BbR0mevbH-rzgXYA"), ("hairdye-pastel-red", "Permanent Rebel Rose Hair Dye", "[hairdyepotionconsumable_pastelred]", "aziIarpSREmcT6vihduPwA"), ("hairdye-pastel-orange", "Permanent PVPeach Hair Dye", "[hairdyepotionconsumable_pastelorange]", "0RUmjv2bL0ydbKU61dlqlg"), ("hairdye-pastel-yellow", "Permanent Standee Sandy Hair Dye", "[hairdyepotionconsumable_pastelyellow]", "H65ddsxAY0m2sccMrF48Aw"), ("hairdye-pastel-green", "Permanent Frontier Forest Hair Dye", "[hairdyepotionconsumable_pastelgreen]", "LBz3a9520kiOOMMOwsTExg"), ("hairdye-pastel-teal", "Permanent Jungle Jade Hair Dye", "[hairdyepotionconsumable_pastelteal]", "Sc8S3QiTIUuXNJZD6geY1Q"), ("hairdye-pastel-cyan", "Permanent Circuit Cerulean Hair Dye", "[hairdyepotionconsumable_pastelcyan]", "W5E2gDCKY0SXaKytP89-OA"), ("hairdye-pastel-blue", "Permanent Paula's Periwinkle Hair Dye", "[hairdyepotionconsumable_pastelblue]", "_2QGtfHzuUSq0eyI84rImw"), ("hairdye-pastel-purple", "Permanent Lounge Lavender Hair Dye", "[hairdyepotionconsumable_pastelpurple]", "qp_vRXZQPU2rlx_IU1Vr8Q"), ("hairdye-pastel-pink", "Permanent Maker Mauve Hair Dye", "[hairdyepotionconsumable_pastelpink]", "G129V_kVXkSLN2OG8EjSqQ"), }; /// Every permanent hair-dye store slug — used by the admin /// "unlock all items" action to drop the whole hair-dye set into a /// player's (hair dyes are owned as /// consumable slugs, not avatar GUIDs). public static IEnumerable AllHairDyeSlugs => PermanentHairDyes.Select(d => d.Slug); private bool EnableWatchGiftDrops => config.GetValue("Store:EnableWatchGiftDrops", true); private int MaxWatchGiftDrops => // 0 means no cap. Keep the switch configurable in case a // deployment needs to temporarily reduce the shelf size while // debugging a bad catalog row. config.GetValue("Store:MaxWatchGiftDrops", 0); /// Idempotent seed of a starter catalog so the store is /// non-empty on first boot. Only inserts items whose Slug isn't /// already in the DB; safe to call repeatedly. public async Task SeedAsync() { var defaults = new (string Slug, string Name, string Desc, string Category, long Price, string Image, string Storefront)[] { // Hats / heads ("dorknet-cap-classic", "Classic Cap", "A simple cap. Comfortable, unremarkable.", "head", 150, "store_classic_cap.png", "main"), ("dorknet-bowler-hat", "Bowler Hat", "Tipped at a jaunty angle. Goes with everything.", "head", 400, "store_bowler_hat.png", "main"), ("dorknet-witch-hat", "Witch Hat", "Pointy. Definitely magical.", "head", 650, "store_witch_hat.png", "main"), // Torso ("dorknet-tee-server", "Server Crew Tee", "Sworn to defend the JWT secret.", "torso", 300, "store_tee_server.png", "main"), ("dorknet-hoodie-cyber", "Cyber Hoodie", "Glowing accents. Optimised for late-night coding.", "torso", 900, "store_hoodie_cyber.png","main"), ("dorknet-jacket-bomber", "Bomber Jacket", "Sleek, bomber-style — for laser tag and lurking.", "torso", 850, "store_jacket_bomber.png","main"), // Legs ("dorknet-jeans-classic", "Blue Jeans", "Standard issue. Goes with anything.", "legs", 200, "store_jeans.png", "main"), ("dorknet-cargo-pants", "Cargo Pants", "Lots of pockets — for inventions, snacks, secrets.", "legs", 450, "store_cargo_pants.png", "main"), // Feet ("dorknet-sneakers", "Sneakers", "Soft soles. Quiet on the dorm floor.", "feet", 250, "store_sneakers.png", "main"), ("dorknet-boots-combat", "Combat Boots", "Polished. Heavy. Great for stomping.", "feet", 550, "store_boots_combat.png","main"), // Accessories ("dorknet-shades", "Sunglasses", "Maximum chill. Slightly inappropriate indoors.", "accessory", 300, "store_shades.png", "main"), ("dorknet-watch-rgb", "RGB Smartwatch", "Glowing wrist accessory. Cycles through every colour.", "accessory", 700, "store_watch_rgb.png", "main"), ("dorknet-cape-velvet", "Velvet Cape", "Floor-length. Suitable for entering a room dramatically.", "accessory", 1200, "store_cape_velvet.png", "main"), // Hair ("dorknet-hair-mohawk", "Mohawk", "Sharp. Loud. Possibly aerodynamic.", "hair", 800, "store_hair_mohawk.png", "main"), ("dorknet-hair-flowing", "Flowing Locks", "Long. Glossy. Definitely waving in some imaginary breeze.", "hair", 650, "store_hair_flowing.png","main"), // Face ("dorknet-face-grin", "Permanent Grin", "A confident, slightly unsettling smile.", "face", 350, "store_face_grin.png", "main"), ("dorknet-face-monocle", "Monocle & Mustache", "Distinguished. Possibly inherited.", "face", 550, "store_face_monocle.png","main"), // Consumables ("dorknet-firework", "Firework", "One-shot launch. Spectacular. Not edible.", "consumable", 50, "store_firework.png", "main"), ("dorknet-pizza-slice", "Pizza Slice", "Looks great on the watch. Also restores 5 imaginary HP.", "consumable", 25, "store_pizza.png", "main"), // Room templates ("dorknet-template-cozy", "Cozy Cabin Template", "A wood-cabin starter for new rooms — fireplace included.", "roomtemplate", 1500, "store_template_cozy.png", "main"), // Limited-time items in the gift drop storefront ("dorknet-gift-mystery1", "Mystery Box (Tier 1)","Contains a randomised cosmetic. Open with caution.", "consumable", 100, "store_mystery_t1.png", "giftdrop:1"), ("dorknet-gift-mystery2", "Mystery Box (Tier 2)","A heftier mystery box. More chance of rare drops.", "consumable", 500, "store_mystery_t2.png", "giftdrop:1"), ("dorknet-gift-aura", "Golden Aura", "Limited-time aura cosmetic — radiates while you're nearby.","accessory", 2500, "store_gift_aura.png", "giftdrop:2"), }; var existing = await db.StoreItems .Select(i => i.Slug) .ToListAsync(); var existingSet = new HashSet(existing, StringComparer.OrdinalIgnoreCase); // Drop stale wardrobe-* rows whose embedded GUID isn't in the // current avatar_item_lookup.json safelist. The previous seed // pulled GUIDs from outfits_assets_*.bundle MonoBehaviours // (mask/swatch ASSET GUIDs, not AvatarItem GUIDs); those tiles // can't render their 3D preview because the watch's // avatarItemPrefabLookup doesn't have those keys. Removing // them prevents broken tiles from cluttering the store. var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids; if (safe.Count > 0) { // Skip wardrobe-colored-* — those resolve through the color // variant catalog, not the safe-GUID set, and the substring // after "wardrobe-" is never a bare GUID anyway. var stale = await db.StoreItems .Where(i => i.Slug.StartsWith("wardrobe-") && !i.Slug.StartsWith(WardrobeColoredPrefix)) .Select(i => new { i.Id, i.Slug }) .ToListAsync(); var toDelete = new List(); foreach (var row in stale) { var guid = row.Slug.Substring("wardrobe-".Length); if (!safe.Contains(guid)) toDelete.Add(row.Id); } if (toDelete.Count > 0) { await db.StoreItems .Where(i => toDelete.Contains(i.Id)) .ExecuteDeleteAsync(); // Re-pull the existing-slug set so the new seed below // re-inserts with the correct GUIDs. existing = await db.StoreItems.Select(i => i.Slug).ToListAsync(); existingSet = new HashSet(existing, StringComparer.OrdinalIgnoreCase); } } // Drop orphan wardrobe-colored-* rows whose slug isn't in the // current color-variant catalog. When we remove an entry from // data/color_variants.json (e.g. because the asset's swatch // colors look wrong in-game), the corresponding StoreItems row // would otherwise linger as a broken tile. if (_variantCatalog.Value.Count > 0) { var validColored = new HashSet(_variantCatalog.Value.Keys, StringComparer.OrdinalIgnoreCase); var orphanColored = await db.StoreItems .Where(i => i.Slug.StartsWith(WardrobeColoredPrefix)) .Select(i => new { i.Id, i.Slug }) .ToListAsync(); var coloredToDelete = orphanColored .Where(r => !validColored.Contains(r.Slug)) .Select(r => r.Id) .ToList(); if (coloredToDelete.Count > 0) { await db.StoreItems .Where(i => coloredToDelete.Contains(i.Id)) .ExecuteDeleteAsync(); existing = await db.StoreItems.Select(i => i.Slug).ToListAsync(); existingSet = new HashSet(existing, StringComparer.OrdinalIgnoreCase); } } var added = false; foreach (var entry in defaults) { if (existingSet.Contains(entry.Slug)) continue; db.StoreItems.Add(new StoreItemEntity { Slug = entry.Slug, DisplayName = entry.Name, Description = entry.Desc, Category = entry.Category, Price = StoreItemPrice, CurrencyType = 2, ImageName = entry.Image, Storefront = entry.Storefront, IsActive = true, IsLimitedTime = entry.Storefront.StartsWith("giftdrop:"), }); added = true; } if (added) await db.SaveChangesAsync(); await SeedGameCatalogAdditionsAsync(existingSet); await SeedEquipmentSkinsAsync(existingSet); await SeedClothingFromGameAsync(existingSet); await SeedColorVariantsAsync(existingSet); await NormalizeStorePricesAsync(); } private async Task SeedGameCatalogAdditionsAsync(HashSet existingSlugs) { var added = false; foreach (var dye in PermanentHairDyes) { if (existingSlugs.Contains(dye.Slug)) continue; db.StoreItems.Add(new StoreItemEntity { Slug = dye.Slug, DisplayName = dye.Name, Description = "Permanent hair dye.", Category = "consumable", Price = StoreItemPrice, CurrencyType = 2, ImageName = string.Empty, Storefront = "main", IsActive = true, IsLimitedTime = false, }); existingSlugs.Add(dye.Slug); added = true; } if (added) await db.SaveChangesAsync(); } // ── Equipment skins ──────────────────────────────────────────── // Source: data/equipment_skins.json. The old client does not infer // equipment reward payloads from a store slug; it expects the exact // PrefabName + ModificationGuid pair from the equipment skin runtime // config. Keep those values data-driven so new verified skin rows can // be added without changing controller code. public sealed class EquipmentSkinEntry { public string prefab_name { get; set; } = string.Empty; public string modification_guid { get; set; } = string.Empty; public string display_name { get; set; } = string.Empty; public string tooltip { get; set; } = string.Empty; public int rarity { get; set; } public string storefront { get; set; } = "main"; public long price { get; set; } = StoreItemPrice; public string image_name { get; set; } = string.Empty; } private sealed class EquipmentSkinFile { public List items { get; set; } = new(); } private static readonly Lazy> _equipmentSkinCatalog = new(LoadEquipmentSkinCatalog); public static IReadOnlyDictionary EquipmentSkinCatalog => _equipmentSkinCatalog.Value; private static Dictionary LoadEquipmentSkinCatalog() { var candidates = new[] { Path.Combine(AppContext.BaseDirectory, "data", "equipment_skins.json"), Path.Combine(AppContext.BaseDirectory, "Data", "equipment_skins.json"), Path.Combine(Directory.GetCurrentDirectory(), "data", "equipment_skins.json"), Path.Combine(Directory.GetCurrentDirectory(), "DorkNet.Server", "Data", "equipment_skins.json"), }; var path = candidates.FirstOrDefault(File.Exists); if (path is null) return new(); try { using var fs = File.OpenRead(path); var data = System.Text.Json.JsonSerializer.Deserialize(fs, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new EquipmentSkinFile(); var bySlug = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in data.items) { if (string.IsNullOrWhiteSpace(item.prefab_name) || string.IsNullOrWhiteSpace(item.modification_guid)) continue; bySlug[EquipmentSkinSlug(item.prefab_name, item.modification_guid)] = item; } return bySlug; } catch { return new(); } } public static string EquipmentSkinSlug(string prefabName, string modificationGuid) { prefabName = (prefabName ?? string.Empty).Trim(); modificationGuid = (modificationGuid ?? string.Empty).Trim(); return $"{EquipmentSkinPrefix}{prefabName}:{modificationGuid}"; } public static bool TryGetEquipmentPayload( string? slug, out string prefabName, out string modificationGuid, out EquipmentSkinEntry? entry) { prefabName = string.Empty; modificationGuid = string.Empty; entry = null; if (string.IsNullOrWhiteSpace(slug)) return false; if (_equipmentSkinCatalog.Value.TryGetValue(slug, out entry)) { prefabName = entry.prefab_name; modificationGuid = entry.modification_guid; return true; } if (!slug.StartsWith(EquipmentSkinPrefix, StringComparison.OrdinalIgnoreCase)) return false; var payload = slug[EquipmentSkinPrefix.Length..]; var split = payload.IndexOf(':'); if (split <= 0 || split >= payload.Length - 1) return false; prefabName = payload[..split]; modificationGuid = payload[(split + 1)..]; return !string.IsNullOrWhiteSpace(prefabName) && !string.IsNullOrWhiteSpace(modificationGuid); } public static bool TryGetEquipmentPayload( string? slug, out string prefabName, out string modificationGuid) => TryGetEquipmentPayload(slug, out prefabName, out modificationGuid, out _); private async Task SeedEquipmentSkinsAsync(HashSet existingSlugs) { var catalog = _equipmentSkinCatalog.Value; if (catalog.Count == 0) return; var added = false; foreach (var (slug, entry) in catalog) { if (existingSlugs.Contains(slug)) continue; var storefront = string.IsNullOrWhiteSpace(entry.storefront) ? "main" : entry.storefront; db.StoreItems.Add(new StoreItemEntity { Slug = slug, DisplayName = string.IsNullOrWhiteSpace(entry.display_name) ? $"{entry.prefab_name} skin" : entry.display_name, Description = string.IsNullOrWhiteSpace(entry.tooltip) ? "Equipment skin." : entry.tooltip, Category = "weapon", Price = entry.price > 0 ? entry.price : StoreItemPrice, CurrencyType = 2, ImageName = entry.image_name, Storefront = storefront, IsActive = true, IsLimitedTime = storefront.StartsWith("giftdrop:", StringComparison.OrdinalIgnoreCase), }); existingSlugs.Add(slug); added = true; } if (added) await db.SaveChangesAsync(); } // ── Color variants ────────────────────────────────────────────── // Source: data/color_variants.json — generated from a static walk // of the 2020 game's outfits_assets_*.bundle files (Python script // at tools/build-color-variants.py). Each entry maps a swatch // (with item GUID + mask GUID) to a color name. We seed one // StoreItem per (item, swatch) with slug // wardrobe-colored-{itemGuid}-{colorLower} // so TryGetAvatarItemPayload can recover the desc on demand. // // The pure wardrobe-{itemGuid} slugs (no color) keep working for // items without color variants, and for legacy single-color SKUs // — those still emit ",,," and the watch picks a default. public const string WardrobeColoredPrefix = "wardrobe-colored-"; public sealed class ColorVariantEntry { public string swatch_name { get; set; } = string.Empty; public string swatch_guid { get; set; } = string.Empty; public string color { get; set; } = string.Empty; public string item_name { get; set; } = string.Empty; public string item_guid { get; set; } = string.Empty; public int? item_outfit_type { get; set; } public string mask_guid { get; set; } = string.Empty; } private sealed class ColorVariantFile { public List matches { get; set; } = new(); } private static readonly Lazy> _variantCatalog = new(LoadColorVariantCatalog); private static Dictionary LoadColorVariantCatalog() { var candidates = new[] { Path.Combine(AppContext.BaseDirectory, "data", "color_variants.json"), Path.Combine(AppContext.BaseDirectory, "Data", "color_variants.json"), Path.Combine(Directory.GetCurrentDirectory(), "data", "color_variants.json"), }; var path = candidates.FirstOrDefault(File.Exists); if (path is null) return new(); try { using var fs = File.OpenRead(path); var data = System.Text.Json.JsonSerializer.Deserialize(fs, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new ColorVariantFile(); var byKey = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var m in data.matches) { if (string.IsNullOrEmpty(m.item_guid) || string.IsNullOrEmpty(m.swatch_guid)) continue; var key = $"{WardrobeColoredPrefix}{m.item_guid}-{m.color}".ToLowerInvariant(); byKey[key] = m; } return byKey; } catch { return new(); } } public static IReadOnlyDictionary ColorVariantCatalog => _variantCatalog.Value; /// Item GUIDs that have at least one color-variant SKU. The /// generic wardrobe-{guid} tile is suppressed for these /// because the colored tiles render the correct swatch; the bare /// tile would emit {guid},,, and fall back to the default /// swatch (often a placeholder colour the player didn't pick). /// private static readonly Lazy> _itemsWithColorVariants = new(() => new HashSet( _variantCatalog.Value.Values.Select(e => e.item_guid), StringComparer.OrdinalIgnoreCase)); private async Task SeedColorVariantsAsync(HashSet existingSlugs) { var catalog = _variantCatalog.Value; if (catalog.Count == 0) return; var added = false; foreach (var (slug, entry) in catalog) { if (existingSlugs.Contains(slug)) continue; // Friendlier display name: strip parens + slot prefix from // the item name, then suffix the color. So // "(Shirt_Lumberjack)" + "Blue" → "Lumberjack — Blue". var inner = (entry.item_name ?? string.Empty).Trim('(', ')'); foreach (var s in new[] { "Hat_", "Shirt_", "Head_", "Hair_", "Eye_", "Wrist_", "Neck_", "Beard_", "Pants_", "Feet_", "Accessory_", "Belt_", "Hand_" }) { if (inner.StartsWith(s)) { inner = inner[s.Length..]; break; } } inner = inner.Replace('_', ' ').Trim(); var displayName = $"{inner} — {entry.color}"; db.StoreItems.Add(new StoreItemEntity { Slug = slug, DisplayName = displayName, Description = $"{entry.color} variant of {inner}.", Category = ColorVariantCategory(entry.item_outfit_type), Price = StoreItemPrice, CurrencyType = 2, ImageName = string.Empty, Storefront = "main", IsActive = true, IsLimitedTime = false, }); existingSlugs.Add(slug); added = true; } if (added) await db.SaveChangesAsync(); } /// Map outfit_type (per avatar_item_lookup.json) to the /// store Category column. Outfit types: 0=Hat, 2=Hair, 10=Eye, /// 20=Beard, 100=Neck, 101=Torso, 200=Wrist (others fall through). private static string ColorVariantCategory(int? outfitType) => outfitType switch { 0 => "head", 2 => "hair", 10 or 20 => "head", 100 or 101 => "torso", 200 => "accessory", _ => "accessory", }; private async Task NormalizeStorePricesAsync() { await db.StoreItems .Where(i => i.Price != StoreItemPrice) .ExecuteUpdateAsync(s => s.SetProperty(i => i.Price, StoreItemPrice)); } /// /// Seed the storefront from /// /// — every AvatarItem GUID in the watch's bundled /// AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup /// becomes one tile. Slug format wardrobe-{avatarItemGuid} /// so the watch's `PurchasableGiftDrop` flow can extract the GUID /// from the slug at time /// and put it in AvatarItemDesc for the watch to render /// the 3D preview prefab in the tile. /// /// The previous implementation pulled from /// data/wardrobe_items.json (Swatch/Mask MonoBehaviour /// GUIDs extracted from outfits_assets_*.bundle). Those /// were ASSET GUIDs, not AvatarItem GUIDs, so the watch couldn't /// resolve them in avatarItemPrefabLookup → tiles came /// out blank. The new source uses the same dump /// AvatarItemsController validates against, so every /// store tile is guaranteed to render. /// private async Task SeedClothingFromGameAsync(HashSet existingSlugs) { var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog; if (catalog.Count == 0) return; // Backfill prices on existing wardrobe rows that were seeded // with the previous Price=0 default — store UI shows "0 // tokens" on every tile otherwise. Idempotent: only updates // rows whose price is still 0. var stalePriceRows = await db.StoreItems .Where(i => i.Slug.StartsWith("wardrobe-") && i.Price == 0) .Select(i => new { i.Id, i.Slug }) .ToListAsync(); if (stalePriceRows.Count > 0) { foreach (var r in stalePriceRows) { var guid = r.Slug.Substring("wardrobe-".Length); await db.StoreItems .Where(s => s.Id == r.Id) .ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, StoreItemPrice)); } } var added = false; foreach (var (guid, dto) in catalog) { var slug = $"wardrobe-{guid}"; if (existingSlugs.Contains(slug)) continue; existingSlugs.Add(slug); db.StoreItems.Add(new StoreItemEntity { Slug = slug, DisplayName = string.IsNullOrEmpty(dto.FriendlyName) ? guid : dto.FriendlyName, Description = "Wardrobe item.", // Category just routes the tile to a tab in the Shop // UI; we map AvatarItemsController.Slot codes to // human-readable category labels. Category = CategoryFromFriendlyName(dto.FriendlyName), Price = StoreItemPrice, CurrencyType = 2, ImageName = string.Empty, Storefront = "main", IsActive = true, IsLimitedTime = false, }); added = true; } if (added) await db.SaveChangesAsync(); } private static int SumCharCodes(string s) { int sum = 0; for (int i = 0; i < s.Length; i++) sum = unchecked(sum * 31 + s[i]); return sum; } private static string CategoryFromFriendlyName(string name) { // Names from the bundled AvatarItemData arrive as e.g. "Hat // Wizard", "Hair AngledBob", "Beard Close", "Eye 2020Glasses", // "Belt PaintballBelt" — the FIRST word is the slot label. // Map to the same bucket strings the previous catch-all used // so any UI code still grouping by category keeps working. var space = name.IndexOf(' '); var head = (space < 0 ? name : name[..space]).ToLowerInvariant(); return head switch { "hat" or "helmet" or "cap" or "crown" or "beanie" or "mask" => "head", "hair" => "hair", "eye" or "glass" or "glasses" or "goggle" or "goggles" => "face", "mouth" or "beard" or "stache" or "moustache" or "mustache" or "copstache" or "seventiesstache" => "face", "shirt" or "jacket" or "hoodie" or "jumpsuit" or "tshirt" or "blouse" or "dress" or "vest" or "torso" or "babydolldress" => "torso", "pant" or "short" or "skirt" or "trouser" => "legs", "shoe" or "boot" or "sock" or "sneaker" => "feet", "neck" or "tie" or "bowtie" or "businesstie" or "cowboyscarf" or "archerquiver" or "quiver" => "neck", "belt" => "belt", "wrist" or "glove" or "watch" or "partyband" => "wrist", _ => "accessory", }; } public async Task> GetActiveByStorefrontAsync(string storefront) => await db.StoreItems .Where(i => i.IsActive && i.Storefront == storefront && (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow)) .OrderBy(i => i.Category).ThenBy(i => i.Price) .ToListAsync(); public async Task> GetRoomStorefrontItemsAsync(int storefrontType) { var key = StorefrontKeyForType(storefrontType); var keys = new[] { key, RroStorefrontKey, AllStorefrontKey }; var rows = await db.StoreItems .Where(i => i.IsActive && keys.Contains(i.Storefront) && (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow)) .OrderBy(i => i.Storefront == key ? 0 : i.Storefront == RroStorefrontKey ? 1 : 2) .ThenBy(i => i.Category) .ThenBy(i => i.Price) .ToListAsync(); if (rows.Any(IsRenderableGiftDrop) || !config.GetValue("Store:FallbackEmptyRoomStorefrontsToWatch", true)) { return rows; } return await GetRenderableGiftDropItemsAsync(); } public async Task> GetAllActiveAsync() => await db.StoreItems .Where(i => i.IsActive && (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow)) .OrderBy(i => i.Storefront).ThenBy(i => i.Category).ThenBy(i => i.Price) .ToListAsync(); public async Task> GetRenderableGiftDropItemsAsync() { // The March 2020 client imports Watch storefront gift drops into // RRUI.Cache.AvatarItemCache and binds them through // DynamicAvatarItemImposter. Only publish slots that the decompiled // watch/store filters handle; unsupported OutfitTypes can enter the // avatar cache and crash during tile preview binding. if (!EnableWatchGiftDrops) return new List(); var rows = await db.StoreItems .Where(i => i.IsActive && (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow)) .OrderBy(i => i.Category).ThenBy(i => i.DisplayName) .ToListAsync(); var filtered = rows .Where(IsRenderableGiftDrop) .ToList(); var max = MaxWatchGiftDrops; return max > 0 ? filtered.Take(max).ToList() : filtered; } public Task GetBySlugAsync(string slug) => db.StoreItems.FirstOrDefaultAsync(i => i.Slug == slug); public Task GetByIdAsync(long id) => db.StoreItems.FirstOrDefaultAsync(i => i.Id == id); public sealed record PurchaseResult(bool Success, string? Error, long? Balance, string? Slug); /// Atomic purchase: re-checks balance, deducts currency, /// appends the item to the player's inventory. Idempotent on /// already-owned items (returns Success=true without re-charging). /// Returns the resulting balance and the slug granted. public async Task PurchaseAsync(long playerId, long itemId) { var item = await GetByIdAsync(itemId); if (item is null || !item.IsActive) return new(false, "item_not_available", null, null); if (item.AvailableUntil is { } until && until <= DateTime.UtcNow) return new(false, "item_expired", null, null); var balance = await level.GetBalanceAsync(playerId, item.CurrencyType); if (balance < item.Price) return new(false, "insufficient_funds", balance, 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)) return new(true, "already_owned", balance, item.Slug); var newWardrobeBalance = await level.GrantCurrencyAsync( playerId, item.CurrencyType, -item.Price, $"purchase:{item.Slug}"); ownedGuids.Add(guid); avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(ownedGuids); avatar.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return new(true, null, newWardrobeBalance, item.Slug); } // Idempotency: own-already → return success without re-charging. var inventory = ParseInventory(avatar.InventoryJson); var existing = inventory.FirstOrDefault(e => e.ItemId == item.Slug); if (existing is not null) { if (string.Equals(item.Category, "consumable", StringComparison.OrdinalIgnoreCase)) { var newBalanceConsumable = await level.GrantCurrencyAsync( playerId, item.CurrencyType, -item.Price, $"purchase:{item.Slug}"); existing.Quantity += 1; avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(inventory); avatar.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return new(true, null, newBalanceConsumable, item.Slug); } return new(true, "already_owned", balance, item.Slug); } var newBalance = await level.GrantCurrencyAsync( playerId, item.CurrencyType, -item.Price, $"purchase:{item.Slug}"); 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, newBalance, item.Slug); } private static List ParseWardrobeInventory(string? json) { if (string.IsNullOrWhiteSpace(json) || json == "[]") return new(); try { var strings = System.Text.Json.JsonSerializer.Deserialize>(json); if (strings is not null) return strings; } catch { } try { var entries = ParseInventory(json); return entries .Select(e => e.ItemId.StartsWith("wardrobe-", StringComparison.Ordinal) ? e.ItemId["wardrobe-".Length..] : e.ItemId) .Select(InventoryAvatarItemDesc) .Where(s => !string.IsNullOrWhiteSpace(s)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } catch { return new(); } } public sealed class InventoryEntry { [System.Text.Json.Serialization.JsonPropertyName("itemId")] public string ItemId { get; set; } = string.Empty; [System.Text.Json.Serialization.JsonPropertyName("quantity")] public int Quantity { get; set; } } public static List ParseInventory(string? json) { if (string.IsNullOrWhiteSpace(json) || json == "[]") return new(); try { return System.Text.Json.JsonSerializer .Deserialize>(json) ?? new(); } catch { return new(); } } /// /// GiftDropStorefront wire shape, verified against ISIL: /// GiftDropStorefront.Deserialize → reads "StoreItems" /// (Cpp2IL_ISIL/.../GiftDropStorefront.txt:98) /// StoreItems is List<PurchasableGiftDrop> /// PurchasableGiftDrop.Deserialize → calls PurchasableItem.Deserialize /// then reads "GiftDrops" as List<StorefrontGiftDrop> /// (PurchasableGiftDrop.txt:118) /// PurchasableItem.Deserialize → REQUIRED keys /// PurchasableItemId / Type / IsFeatured / Prices / SubscriberPrices /// (PurchasableItem.txt:592-654) /// PurchasablePrice.Deserialize → CurrencyType / Price /// (PurchasablePrice.txt:87-94) /// StorefrontGiftDrop.Deserialize → REQUIRED GiftDropId / Rarity /// plus optional FriendlyName / Tooltip / AvatarItemDesc / /// AvatarItemType / ConsumableItemDesc / EquipmentPrefabName / /// EquipmentModificationGuid / IsQuery / Unique / SubscribersOnly / /// Level / Context (StorefrontGiftDrop.txt:829-924). /// /// Each StoreItemEntity becomes one PurchasableGiftDrop holding a /// single StorefrontGiftDrop pointing at the associated wardrobe /// AvatarItem GUID. Only wardrobe-{guid}-prefixed slugs are /// emitted because the 2020 watch storefront binds every gift-drop /// tile through BrowsableAvatarItem. Sending a gift drop with /// AvatarItemType=Outfit and an empty AvatarItemDesc leaves that /// binding half-populated and can crash the client while rendering /// the shop grid. /// public static object ToStorefrontDto(string name, IEnumerable items, string apex = "rec.net", int storefrontType = 3) { var storeItems = items .Where(IsRenderableGiftDrop) .Select((i, idx) => BuildPurchasableGiftDrop(i, idx)) .ToArray(); return new { StorefrontType = storefrontType, StorefrontName = name, // NOT year 9999: that overflows the watch's local-timezone date // parse in some DST timezones → "malformed RecNet response" and a // broken store for that player only. See WireDates. NextUpdate = WireDates.FarFutureIso, NewUntil = WireDates.FarFutureIso, StoreItems = storeItems, }; } private static bool IsRenderableGiftDrop(StoreItemEntity item) { if (TryGetHairDyePayload(item.Slug, out _)) return true; if (TryGetConsumableItemDesc(item.Slug, out _)) return true; if (TryGetEquipmentPayload(item.Slug, out _, out _)) return true; if (!TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc)) return false; if (avatarItemType != StoreAvatarItemTypeOutfit) return false; var guid = avatarItemDesc.Split(',', 2)[0]; return AvatarItemsController.TryGetOutfitType(guid, out var outfitType) && IsWatchStorefrontOutfitType(outfitType); } private static bool IsWatchStorefrontOutfitType(int outfitType) => outfitType is AvatarItemsController.Slot.Hat or AvatarItemsController.Slot.BackHead or AvatarItemsController.Slot.Hair or AvatarItemsController.Slot.Eye or AvatarItemsController.Slot.Mouth or AvatarItemsController.Slot.Neck or AvatarItemsController.Slot.Shirt or AvatarItemsController.Slot.Belt or AvatarItemsController.Slot.Pocket or AvatarItemsController.Slot.Wrist or AvatarItemsController.Slot.Glove or AvatarItemsController.Slot.Watch or AvatarItemsController.Slot.TeamShirt or AvatarItemsController.Slot.TeamWrist; /// One PurchasableGiftDrop carrying one StorefrontGiftDrop. /// PurchasableGiftDrop inherits PurchasableItem so we ship the /// PurchasableItem keys plus the GiftDrops list in the same /// object. private static object BuildPurchasableGiftDrop(StoreItemEntity i, int positionalIndex) { // 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 price = new { CurrencyType = i.CurrencyType, Price = (int)i.Price, }; // 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. // // CRITICAL: the watch's AvatarItem.FromRecNetString does // String.Split(',') and indexes customization[0..2] via // ArraySegmentEnumerator.get_Current — anything shorter than // four comma-separated parts throws ArgumentException ("Offset // and length were out of bounds…") AND, because that exception // bubbles up through OutfitManager.IsAvatarItemAlreadyPurchased // → WatchStoreItemButton.UpdateButtonInteractability → the // store's UI grid render path, the watch crashes mid-tile. Bare // {guid} = 1 part → instant crash on first store-tile // hover; {guid},,, = 4 parts (guid + 3 empty // customizations) → safe. AvatarItemsController.Get() applies // the same suffix to /api/avatar/v4/items entries; we apply it // here too. TryGetAvatarItemPayload(i.Slug, out var avatarItemType, out var avatarItemDesc); TryGetConsumableItemDesc(i.Slug, out var consumableItemDesc); TryGetEquipmentPayload(i.Slug, out var equipmentPrefabName, out var equipmentModificationGuid, out var equipmentEntry); var isHairDye = TryGetHairDyePayload(i.Slug, out var consumableHairDyeDesc, out var hairDyeColorGuid); if (isHairDye) { avatarItemType = StoreAvatarItemTypeHairDye; avatarItemDesc = hairDyeColorGuid; consumableItemDesc = consumableHairDyeDesc; } var giftDrop = new { // REQUIRED. GiftDropId = itemId, Rarity = MapRarity(i.Category), // Optional / nullable string fields the watch reads. FriendlyName = i.DisplayName, Tooltip = i.Description, AvatarItemDesc = avatarItemDesc, AvatarItemDescOrHairDyeDesc = avatarItemDesc, // AvatarItemId is the field the 2023 client's StoreItemListModel // actually reads (store-item +0x38) to resolve the card to a baked // AvatarItem and read its OutfitType for tab placement — verified // vs 2023 ISIL (EMBCEDNHFLB/CLIANCNHNIE, AvatarItem.GIJPCPDKHPO). // AvatarItemDesc alone is NOT the join key, so clothing cards // resolved to OutfitType=-1 and the Shop rendered empty. Ship the // same {guid},,, descriptor (GIJPCPDKHPO accepts bare {guid} or the // composite and falls back to parsing it to the immutable Guid). AvatarItemId = avatarItemDesc, AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType, ConsumableItemDesc = consumableItemDesc, EquipmentPrefabName = equipmentPrefabName, EquipmentModificationGuid = equipmentModificationGuid, IsQuery = false, Unique = true, SubscribersOnly = false, Level = 1, Context = 0, 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(), // PurchasableGiftDrop adds GiftDrops on top. GiftDrops = new[] { giftDrop }, }; } /// GiftManager.GiftRarity wire values: /// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50. private static int MapRarity(string? category) => category?.ToLowerInvariant() switch { "consumable" => 0, "head" or "hair" or "face" => 10, "torso" or "neck" or "belt" or "wrist" => 20, "accessory" => 30, "roomtemplate" => 50, _ => 0, }; public static bool TryGetAvatarItemPayload(string? slug, out int avatarItemType, out string avatarItemDesc) { avatarItemType = StoreAvatarItemTypeOutfit; avatarItemDesc = string.Empty; if (string.IsNullOrWhiteSpace(slug)) return false; // Colored variant slug (wardrobe-colored--) — // resolve via the static catalog to get the swatch + mask // GUIDs. AvatarItem RecNet desc format verified against the // watch's AvatarItemVisualData.ToRecNetString (Cpp2IL_ISIL/.../ // RecRoom/Avatar/Data/Shared/AvatarItemVisualData.txt:474-585): // ,,, // — swatch BEFORE mask, not the other way around. Earlier code // had them flipped, which made the watch read the mask GUID as // a swatch GUID, fail to resolve it, and fall back to the // default swatch. if (slug.StartsWith(WardrobeColoredPrefix, StringComparison.OrdinalIgnoreCase)) { if (_variantCatalog.Value.TryGetValue(slug.ToLowerInvariant(), out var entry)) { avatarItemDesc = $"{entry.item_guid},{entry.swatch_guid},{entry.mask_guid},"; return true; } // Fall through to wardrobe- handling if not in catalog. } if (slug.StartsWith(WardrobePrefix, StringComparison.Ordinal)) { avatarItemDesc = slug[WardrobePrefix.Length..] + ",,,"; return true; } return false; } public static string InventoryAvatarItemDesc(string avatarItemDesc) { if (string.IsNullOrWhiteSpace(avatarItemDesc)) return string.Empty; return avatarItemDesc.EndsWith(",,,", StringComparison.Ordinal) ? avatarItemDesc[..^3] : avatarItemDesc; } public static bool TryGetConsumableItemDesc(string? slug, out string consumableItemDesc) { consumableItemDesc = string.Empty; if (string.IsNullOrWhiteSpace(slug)) return false; if (TryGetHairDyePayload(slug, out consumableItemDesc, out _)) return true; return false; } public static bool TryGetHairDyePayload(string? slug, out string hairDyeDesc) { return TryGetHairDyePayload(slug, out _, out hairDyeDesc); } public static bool TryGetHairDyeName(string? slug, out string name) { name = string.Empty; if (string.IsNullOrWhiteSpace(slug)) return false; foreach (var dye in PermanentHairDyes) { if (!string.Equals(slug, dye.Slug, StringComparison.OrdinalIgnoreCase)) continue; name = dye.Name; return true; } return false; } public static bool TryGetHairDyePayload(string? slug, out string consumableDesc, out string hairColorGuid) { consumableDesc = string.Empty; hairColorGuid = string.Empty; if (string.IsNullOrWhiteSpace(slug)) return false; foreach (var dye in PermanentHairDyes) { if (!string.Equals(slug, dye.Slug, StringComparison.OrdinalIgnoreCase)) continue; consumableDesc = dye.ConsumableDesc; hairColorGuid = dye.HairColorGuid; return true; } return false; } public static object ToItemDto(StoreItemEntity i, string apex = "rec.net") => new { ItemId = i.Id, Slug = i.Slug, Name = i.DisplayName, DisplayName = i.DisplayName, Description = i.Description, Category = i.Category, ImageName = i.ImageName, ImageUrl = string.IsNullOrEmpty(i.ImageName) ? "" : $"https://cdn.{apex}/{i.ImageName}", // Price block — both flat and nested forms so whichever shape // the watch's deserialiser reads, it sees a price. Price = i.Price, CurrencyType = i.CurrencyType, Cost = new { Amount = i.Price, CurrencyType = i.CurrencyType }, IsActive = i.IsActive, IsLimitedTime = i.IsLimitedTime, AvailableUntil = i.AvailableUntil, }; }