using System; using System.Collections.Generic; using System.IO; using System.Linq; using Newtonsoft.Json.Linq; namespace Campus_live_rewrite.api.server { /// /// Hair is free for everybody. /// /// Two halves to "every hair and hair colour", and only one of them needed code: /// /// HAIR ITEMS - the styles themselves - are ordinary avatar items in /// Data/APIS/items/avatar_items.json, and ownership gates them (APIController's /// /api/avatar/v4/items filters the catalog through PlayerDB.OwnedAvatarItemDescs). /// DefaultAvatarItems.json shipped only 39 of the 131 hair guids in the catalog, so the /// rest had to be earned. This class supplies the other 92. /// /// HAIR COLOUR is a storefront price, not an ownership flag. The client keeps its palette /// in HairColorVault, split into NaturalColors (always available) and DyeColors, and /// LinkDyesToStorefront ties each dye swatch to a STORE ITEM - so the 250-token badges on /// the Modify Hair swatches come straight off Data/APIS/GiftDropStores/3.json, where 19 /// "Permanent hair dye (...)" entries are priced at 250. (The 22 "... Hair Dye" entries in /// Consumables.json are a different, already-unlocked thing and were a red herring.) /// IsHairDye + StoreJsonWithFreeDyes below zero that price on the way out, and StoreCatalog /// zeroes it at the till, so a colour costs nothing but still goes through the normal /// purchase that unlocks it for good. /// /// The set is unioned into a player's ownership on READ (see PlayerDB.OwnedAvatarItemDescs) /// rather than written into their wardrobe row: existing accounts get it with no migration, /// and it cannot drift out of sync with the catalog. /// public static class HairGrants { private static HashSet? _hairKeys; private static readonly object _lock = new(); /// /// Every catalog item whose name carries the word "hair", as normalised ownership keys. /// Empty when ConfigCampus.EveryoneGetsAllHair is off. /// /// Matching on the whole word keeps "Chair" and "Mohair" out while still catching the /// handful of hair ACCESSORIES named for it - Bat Hair Bow, Flower Hair Clip, Anime Hair /// Hat. Those are deliberately included: they are hair pieces, and eight free accessories /// is a better outcome than a rule nobody can predict from the item's name. /// public static HashSet FreeHairKeys() { if (!ConfigCampus.EveryoneGetsAllHair) return new HashSet(StringComparer.OrdinalIgnoreCase); if (_hairKeys != null) return _hairKeys; lock (_lock) { if (_hairKeys != null) return _hairKeys; var keys = new HashSet(StringComparer.OrdinalIgnoreCase); try { string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "avatar_items.json"); if (!File.Exists(path)) { Console.WriteLine($"[hair] {path} not found - no free hair granted"); _hairKeys = keys; return _hairKeys; } var rx = new System.Text.RegularExpressions.Regex( @"\bhair\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase); foreach (var e in JArray.Parse(File.ReadAllText(path))) { string name = (string?)e["FriendlyName"] ?? ""; if (!rx.IsMatch(name)) continue; // Grant the descriptor exactly as the catalog writes it, normalised the // same way ownership is compared - a coloured variant is its own entry // (base guid + combination), so every variant of a hair item lands too. string key = dbs.PlayerDB.NormalizeAvatarItemDesc((string?)e["AvatarItemDesc"]); if (key.Length > 0) keys.Add(key); } // The dye colours are owned too - same set, so a purchase check, a wardrobe // read and the store's IsItemOwned all answer the same way. foreach (var k in FreeHairDyeKeys()) keys.Add(k); Console.WriteLine($"[hair] every account owns {keys.Count} hair item(s) (ConfigCampus.EveryoneGetsAllHair)"); } catch (Exception ex) { Console.WriteLine($"[hair] could not build the free-hair set: {ex.Message}"); } _hairKeys = keys; return _hairKeys; } } private static readonly System.Text.RegularExpressions.Regex _dyeRx = new(@"hair\s*dye", System.Text.RegularExpressions.RegexOptions.IgnoreCase); /// Is this store item a hair dye? Matches "Permanent hair dye (Bucket Orange)". public static bool IsHairDye(string? friendlyName) => ConfigCampus.FreeHairDyes && !string.IsNullOrEmpty(friendlyName) && _dyeRx.IsMatch(friendlyName); // One cached rewrite per store file, keyed on path + last-write time so an edited or // redeployed catalog is picked up without a restart. private static readonly Dictionary _freeStores = new(StringComparer.OrdinalIgnoreCase); /// /// A gift-drop store file with every hair dye priced at 0, or null when the file holds no /// dyes (the caller then serves the original off disk and nothing is copied into memory). /// /// Zero every hair dye's price in a store object, in place. Returns how many. public static int ZeroDyePrices(JObject root) { if (!ConfigCampus.FreeHairDyes) return 0; int changed = 0; foreach (var it in root["StoreItems"] as JArray ?? new JArray()) { if (it is not JObject o) continue; if (!IsHairDye((string?)o["GiftDrop"]?["FriendlyName"])) continue; foreach (var key in new[] { "Prices", "SubscriberPrices" }) foreach (var pr in o[key] as JArray ?? new JArray()) if (pr is JObject pj) pj["Price"] = 0; changed++; } return changed; } /// Drop rows HiddenItemsStorefronts says the client cannot draw. Returns how many. public static int HideUnloadableItems(JObject root) { if (!ConfigCampus.HiddenItemsStorefronts) return 0; if (root["StoreItems"] is not JArray arr) return 0; int n = 0; for (int i = arr.Count - 1; i >= 0; i--) { string? desc = (string?)arr[i]?["GiftDrop"]?["AvatarItemDesc"]; if (!Classes.ItemGuids.StoreCanList(desc)) { arr.RemoveAt(i); n++; } } return n; } public static string? StoreJsonWithFreeDyes(string path) { try { var stamp = File.GetLastWriteTimeUtc(path); lock (_lock) { if (_freeStores.TryGetValue(path, out var hit) && hit.Stamp == stamp) return hit.Json; } var root = JObject.Parse(File.ReadAllText(path)); int changed = 0; foreach (var it in root["StoreItems"] as JArray ?? new JArray()) { if (it is not JObject o) continue; if (!IsHairDye((string?)o["GiftDrop"]?["FriendlyName"])) continue; foreach (var key in new[] { "Prices", "SubscriberPrices" }) foreach (var pr in o[key] as JArray ?? new JArray()) if (pr is JObject pj) pj["Price"] = 0; changed++; } changed += HideUnloadableItems(root); // HiddenItemsStorefronts applies to every store if (changed == 0) return null; // nothing to rewrite - let the caller stream the file string json = root.ToString(Newtonsoft.Json.Formatting.None); lock (_lock) _freeStores[path] = (stamp, json); Console.WriteLine($"[hair] {Path.GetFileName(path)}: {changed} hair dye(s) served at 0 (ConfigCampus.FreeHairDyes)"); return json; } catch (Exception ex) { Console.WriteLine($"[hair] could not free the dyes in {Path.GetFileName(path)}: {ex.Message}"); return null; // fall back to the untouched file } } // ---- hair dye as OWNED INVENTORY ------------------------------------------------- // // Pricing the dyes at 0 made the swatches read a0 instead of a250 - still a price tag, // still a purchase. A dye the player OWNS has no badge at all, and ownership is what the // client actually tests: StoreItemModel.IsItemOwned, resolved against the avatar-item // inventory, because a dye's GiftDrop is an avatar item (AvatarItemType 1, an // AvatarItemDesc, and an empty ConsumableItemDesc). // // The catch is that the 19 dye descriptors are NOT in avatar_items.json, so // /api/avatar/v4/items - which filters the CATALOG by what you own - could never emit // them however they were marked owned. So the records are synthesised here from the store // entries and appended to that response, and their descs join FreeHairKeys() so every // other ownership check agrees. private static JArray? _dyeItems; // Catalog AvatarItemIds run to 9503, and the dyes' own GiftDropIds (2554-2572) are ALREADY // taken by real catalog items - so a synthetic range well clear of both. Reusing GiftDropId // here would have made 19 items collide with 19 existing ones. private const int DyeAvatarItemIdBase = 9_600_000; /// /// Every hair dye as an avatar-item record, shaped like an avatar_items.json entry. /// Empty when ConfigCampus.FreeHairDyes is off. /// public static JArray FreeHairDyeItems() { if (!ConfigCampus.FreeHairDyes) return new JArray(); if (_dyeItems != null) return _dyeItems; lock (_lock) { if (_dyeItems != null) return _dyeItems; var arr = new JArray(); try { string dir = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "GiftDropStores"); if (Directory.Exists(dir)) { int n = 0; var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var file in Directory.GetFiles(dir, "*.json").OrderBy(f => f)) { JObject root; try { root = JObject.Parse(File.ReadAllText(file)); } catch { continue; } foreach (var it in root["StoreItems"] as JArray ?? new JArray()) { var gd = it?["GiftDrop"] as JObject; if (gd == null) continue; string name = (string?)gd["FriendlyName"] ?? ""; if (!IsHairDye(name)) continue; string desc = (string?)gd["AvatarItemDesc"] ?? ""; if (string.IsNullOrWhiteSpace(desc)) continue; // nothing to own if (!seen.Add(desc)) continue; // same dye in two stores arr.Add(new JObject { ["AvatarItemDesc"] = desc, ["AvatarItemType"] = (int?)gd["AvatarItemType"] ?? 1, ["PlatformMask"] = -1, ["FriendlyName"] = name, ["Tooltip"] = null, ["Rarity"] = (int?)gd["Rarity"] ?? -1, ["TagList"] = null, ["AvatarItemId"] = DyeAvatarItemIdBase + n, ["IsBaseAvatarItem"] = false, ["CreatedAt"] = "2018-01-01T00:00:00.000Z", ["ThumbnailImage"] = null }); n++; } } if (n > 0) Console.WriteLine($"[hair] every account owns {n} hair dye colour(s) (ConfigCampus.FreeHairDyes)"); } } catch (Exception ex) { Console.WriteLine($"[hair] could not build the free-dye set: {ex.Message}"); } _dyeItems = arr; return _dyeItems; } } /// The dye descriptors, as ownership keys. public static IEnumerable FreeHairDyeKeys() => FreeHairDyeItems() .Select(e => dbs.PlayerDB.NormalizeAvatarItemDesc((string?)e["AvatarItemDesc"])) .Where(k => k.Length > 0); } }