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;
}
// Food / KO / camera-film / potion consumables from the client's
// baked ConsumableCollectionRuntimeConfig (data/consumables.json).
// These populate the watch Shop's Consumables tab; each carries the
// client's literal ConsumableItemDesc ("[FoodConsumable_RootBeer]")
// so the tile binds to the right baked consumable prefab.
foreach (var c in ConsumableCatalog.Values)
{
if (existingSlugs.Contains(c.slug)) continue;
db.StoreItems.Add(new StoreItemEntity
{
Slug = c.slug,
DisplayName = c.name,
Description = $"{c.family_label} consumable.",
Category = "consumable",
Price = StoreItemPrice,
CurrencyType = 2,
ImageName = string.Empty,
Storefront = "main",
IsActive = true,
IsLimitedTime = false,
});
existingSlugs.Add(c.slug);
added = true;
}
if (added) await db.SaveChangesAsync();
}
// ── Consumables (food / potions / KO / camera film) ────────────────
// Source: data/consumables.json, extracted offline from the client's
// ConsumableCollectionRuntimeConfig MonoBehaviour (resources.assets).
// The consumable_desc is the client's literal item key, fed
// verbatim into the gift-drop's ConsumableItemDesc so the Shop tile
// resolves to the baked consumable — same mechanism as hair dyes.
public sealed class ConsumableEntry
{
public string slug { get; set; } = string.Empty;
public string name { get; set; } = string.Empty;
public string consumable_desc { get; set; } = string.Empty;
public string guid { get; set; } = string.Empty;
public string family { get; set; } = string.Empty;
public string family_label { get; set; } = "Consumable";
}
private static readonly Lazy> _consumableCatalog =
new(LoadConsumableCatalog);
public static IReadOnlyDictionary ConsumableCatalog =>
_consumableCatalog.Value;
private static Dictionary LoadConsumableCatalog()
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "data", "consumables.json"),
Path.Combine(AppContext.BaseDirectory, "Data", "consumables.json"),
Path.Combine(Directory.GetCurrentDirectory(), "data", "consumables.json"),
Path.Combine(Directory.GetCurrentDirectory(), "DorkNet.Server", "Data", "consumables.json"),
};
var path = candidates.FirstOrDefault(File.Exists);
if (path is null) return new();
try
{
using var fs = File.OpenRead(path);
var rows = System.Text.Json.JsonSerializer.Deserialize>(fs,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List();
var bySlug = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
if (string.IsNullOrWhiteSpace(row.slug) || string.IsNullOrWhiteSpace(row.consumable_desc))
continue;
bySlug[row.slug] = row;
}
return bySlug;
}
catch
{
return new();
}
}
// ── Equipment skins ────────────────────────────────────────────
// Source: data/equipment_skins.json. The old client does not infer
// equipment reward payloads from a store slug; it expects the exact
// 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;
// 2023 colored-variant identity + equip string. A variant is
// identified by its COMBINATION guid (not the swatch — one swatch
// material is shared across many colorways), and the client equips
// the descriptor VERBATIM as an opaque combinationLookup key. See
// color_variants.json (schema v2, dumped from allPossibleCombinations).
public string combination_id { get; set; } = string.Empty;
public string descriptor { 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)
{
// A variant is valid if it has an item + a descriptor (the
// verbatim equip string). swatch_guid is now informational
// only — it is NOT the identity (shared across colorways).
if (string.IsNullOrEmpty(m.item_guid) || string.IsNullOrEmpty(m.descriptor)) 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);
}
/// 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();
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 = PurchasableItemIdFor(i);
var giftDrop = BuildGiftDrop(i, itemId);
var price = new
{
CurrencyType = i.CurrencyType,
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