using System.Text.Json; using Ananta.Server.Configuration; namespace Ananta.Server.ClientData.Client4229938; /// /// Client economy tables (mall commodities, drops, gacha pools/contents/tier rules) imported from the /// 4229938 config dump into paths.clientConfigs. /// /// Unlike ClientConfigRepository these tables are optional: a missing or malformed file only disables /// the matching feature (mall purchase / gacha draw) instead of refusing to start, because the rest of /// the server must keep working when a dump is incomplete. /// internal static class EconomyConfigRepository { private static readonly Lazy Cache = new(Load); /// Forces the (optional) tables to load so the startup log reports what was imported. internal static void Warmup() => _ = Cache.Value; internal static bool TryMallCommodity(uint id, out MallCommodity commodity) => Cache.Value.Mall.TryGetValue(id, out commodity!); internal static bool TryDrop(uint id, out DropRow drop) => Cache.Value.Drops.TryGetValue(id, out drop!); internal static bool TryPool(uint id, out GachaPool pool) => Cache.Value.Pools.TryGetValue(id, out pool!); internal static bool TryTierRule(uint id, out GachaTierRule rule) => Cache.Value.TierRules.TryGetValue(id, out rule!); internal static bool TryTradeItem(uint id, out TradeItem item) => Cache.Value.TradeItems.TryGetValue(id, out item!); internal static IReadOnlyList PoolContents(uint poolId) => Cache.Value.ContentsByPool.TryGetValue(poolId, out var contents) ? contents : []; /// One mall shelf row. Price is the list price, DiscountPrice the current charge (0 = none). internal sealed record MallCommodity( uint Id, uint MallId, uint Type, uint BindId, uint ConsumeItemId, double Price, double DiscountPrice, uint DropId, uint LimitNum, IReadOnlyList CommodityBindIds) { internal double UnitPrice => DiscountPrice > 0 ? DiscountPrice : Price; } /// /// One drop row. items are always granted, /// picks one entry and a random count in [Min, Max], picks one entry by /// relative weight (Item3 carries fractional weights, so they are doubles). /// internal sealed record DropRow( uint Id, double Money, double BindingGold, IReadOnlyList<(uint Id, uint Count)> Guaranteed, IReadOnlyList<(uint Id, uint Min, uint Max)> Ranged, IReadOnlyList<(uint Id, double Weight)> Weighted) { internal bool IsEmpty => Guaranteed.Count == 0 && Ranged.Count == 0 && Weighted.Count == 0 && Money == 0 && BindingGold == 0; } internal sealed record GachaPool( uint Id, bool IsDrop, uint MoneyId, uint CommodityId, uint GrandTierRuleId, IReadOnlyList<(uint DrawCount, uint Cost)> Costs) { /// Authored cost for the requested draw count, falling back to per-draw cost × count. internal double CostFor(uint drawCount) { foreach (var (count, cost) in Costs) { if (count == drawCount) return cost; } foreach (var (count, cost) in Costs) { if (count == 1) return cost * drawCount; } return Costs.Count > 0 ? Costs[0].Cost : 0; } } internal sealed record GachaContent( uint Id, uint PoolId, uint TierRarity, uint DropId, double Weight, uint Quantity, uint Quality, uint DuplicateReturnDropId, uint DuplicateReturnCount) { /// SS-tier row: the UI plays the extra grand-prize video for these draws. internal bool IsGrandPrize => TierRarity >= 4 || Quality >= 5; } /// /// Pity ramp for one tier rule. 1 pools ramp their grand-prize chance /// from by every draw /// once is reached and guarantee it at . /// 0 pools instead pick a tier from the cumulative /// table (scale 100000) with no pity at all. /// internal sealed record GachaTierRule( uint Id, uint RuleType, uint PityThreshold, uint RampStartDraw, uint BaseProbability, uint ProbabilityIncrement, IReadOnlyList GrandPrizeProbability); /// /// One trading-post row. 0 hands out as an /// ordinary backpack item, 1 unlocks the wardrobe piece , 3 is a /// household collectible (also ) and 2 is a fashion suit that retail only /// trades through the order book. /// internal sealed record TradeItem( uint Id, uint ItemType, uint ItemId, uint Tab, uint FashionId, uint FashionSuitId, uint MinPrice, uint MaxPrice, IReadOnlyList BucketRanges) { /// Cheapest price the row is offered at - the floor of its authored bucket ranges. internal uint MarketPrice { get { if (MinPrice > 0) return MinPrice; foreach (var bucket in BucketRanges) { if (bucket > 0) return bucket; } return 0; } } } private sealed class Data { internal Dictionary Mall { get; init; } = []; internal Dictionary Drops { get; init; } = []; internal Dictionary Pools { get; init; } = []; internal Dictionary TierRules { get; init; } = []; internal Dictionary> ContentsByPool { get; init; } = []; internal Dictionary TradeItems { get; init; } = []; internal Dictionary Items { get; init; } = []; } private static Data Load() { var cfg = PrivateServerConfigStore.Current; var root = PrivateServerConfigStore.ResolveProjectPath(cfg.Paths.ClientConfigs); var warnings = new List(); var mall = new Dictionary(); foreach (var row in ReadRecords(Path.Combine(root, "MallCommodityConfig.json"), warnings)) { if (!TryUInt32(row, "Id", out var id) || id == 0) continue; TryUInt32(row, "BelongMallId", out var mallId); TryUInt32(row, "Type", out var type); TryUInt32(row, "BindId", out var bindId); TryUInt32(row, "ConsumeItemId", out var consumeItemId); TryDouble(row, "Price", out var price); TryDouble(row, "DiscountPrice", out var discountPrice); TryUInt32(row, "DropId", out var dropId); TryUInt32(row, "LimitNum", out var limitNum); mall[id] = new MallCommodity( id, mallId, type, bindId, consumeItemId, price, discountPrice, dropId, limitNum, ReadUInt32Array(row, "CommodityBindId")); } var drops = new Dictionary(); foreach (var row in ReadRecords(Path.Combine(root, "DropConfig.json"), warnings)) { if (!TryUInt32(row, "Id", out var id) || id == 0) continue; TryDouble(row, "Money", out var money); TryDouble(row, "BindingGold", out var bindingGold); drops[id] = new DropRow( id, money, bindingGold, ReadItemList(row, "Item1"), ReadItemRangeList(row, "Item2"), ReadItemWeightList(row, "Item3")); } var pools = new Dictionary(); foreach (var row in ReadRecords(Path.Combine(root, "GachaPoolConfig.json"), warnings)) { if (!TryUInt32(row, "Id", out var id) || id == 0) continue; TryUInt32(row, "MoneyId", out var moneyId); TryUInt32(row, "CommodityId", out var commodityId); TryUInt32(row, "SS_TierRuleId", out var grandTierRuleId); pools[id] = new GachaPool( id, ReadBool(row, "isDrop"), moneyId, commodityId, grandTierRuleId, ReadCostList(row, "CostCount")); } var tierRules = new Dictionary(); foreach (var row in ReadRecords(Path.Combine(root, "GachaPoolTierRuleConfig.json"), warnings)) { if (!TryUInt32(row, "Id", out var id) || id == 0) continue; TryUInt32(row, "RuleType", out var ruleType); TryUInt32(row, "PityThreshold", out var pityThreshold); TryUInt32(row, "RampStartDraw", out var rampStartDraw); TryUInt32(row, "BaseProbability", out var baseProbability); TryUInt32(row, "ProbabilityIncrement", out var probabilityIncrement); tierRules[id] = new GachaTierRule( id, ruleType, pityThreshold, rampStartDraw, baseProbability, probabilityIncrement, ReadUInt32Array(row, "GrandPrizeProbability")); } var contentsByPool = new Dictionary>(); foreach (var row in ReadRecords(Path.Combine(root, "GachaPoolContentConfig.json"), warnings)) { if (!TryUInt32(row, "Id", out var id) || id == 0) continue; if (!TryUInt32(row, "PoolId", out var poolId) || poolId == 0) continue; TryUInt32(row, "PoolTierRarity", out var tierRarity); TryUInt32(row, "dropId", out var dropId); TryDouble(row, "weight", out var weight); TryUInt32(row, "Quantity", out var quantity); TryUInt32(row, "Quality", out var quality); TryUInt32(row, "duplicateReturnDropId", out var duplicateDropId); TryUInt32(row, "duplicateReturnCount", out var duplicateCount); if (!contentsByPool.TryGetValue(poolId, out var list)) { list = []; contentsByPool[poolId] = list; } list.Add(new GachaContent( id, poolId, tierRarity, dropId, weight < 0 ? 0 : weight, quantity, quality, duplicateDropId, duplicateCount)); } var tradeItems = new Dictionary(); foreach (var row in ReadRecords(Path.Combine(root, "TradeItemConfig.json"), warnings)) { if (!TryUInt32(row, "Id", out var id) || id == 0) continue; TryUInt32(row, "ItemType", out var itemType); TryUInt32(row, "ItemId", out var itemId); TryUInt32(row, "Tab", out var tab); TryUInt32(row, "FashionId", out var fashionId); TryUInt32(row, "FashionSuitId", out var fashionSuitId); TryUInt32(row, "MinPrice", out var minPrice); TryUInt32(row, "MaxPrice", out var maxPrice); tradeItems[id] = new TradeItem( id, itemType, itemId, tab, fashionId, fashionSuitId, minPrice, maxPrice, ReadUInt32Array(row, "BucketRanges")); } foreach (var message in warnings) Console.WriteLine($"[ECONOMY] {message}"); Console.WriteLine( $"[ECONOMY] mall={mall.Count} drops={drops.Count} pools={pools.Count} " + $"gachaContents={contentsByPool.Values.Sum(x => x.Count)} tierRules={tierRules.Count} " + $"tradeItems={tradeItems.Count} source={root}"); var items = new Dictionary(); foreach (var row in ReadRecords(Path.Combine(root, "ConsumableConfig.json"), warnings)) { if (!TryUInt32(row, "Id", out var id) || id == 0) continue; TryUInt32(row, "Type", out var subType); TryUInt32(row, "Quality", out var quality); var name = ReadString(row, "Name"); items[id] = new ConsumableItem(id, name, subType, quality); } return new Data { Items = items, Mall = mall, Drops = drops, Pools = pools, TierRules = tierRules, ContentsByPool = contentsByPool, TradeItems = tradeItems, }; } private static JsonElement[] ReadRecords(string path, List warnings) { if (!File.Exists(path)) { warnings.Add($"optional client config is missing: {path}"); return []; } try { using var doc = JsonDocument.Parse(File.ReadAllText(path)); if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array) { warnings.Add($"client config has no 'records' array: {path}"); return []; } return records.EnumerateArray().Select(x => x.Clone()).ToArray(); } catch (Exception ex) { warnings.Add($"client config failed to parse ({ex.GetType().Name}: {ex.Message}): {path}"); return []; } } // Dump records may carry fully qualified keys ("LT.ConfigGen.IFoo.Id"), so match on the leaf name. private static bool TryProperty(JsonElement row, string name, out JsonElement value) { if (row.TryGetProperty(name, out value)) return true; foreach (var property in row.EnumerateObject()) { if (property.Name.AsSpan()[(property.Name.LastIndexOf('.') + 1)..].SequenceEqual(name)) { value = property.Value; return true; } } value = default; return false; } private static bool TryUInt32(JsonElement row, string name, out uint result) { result = 0; return TryProperty(row, name, out var value) && value.TryGetUInt32(out result); } private static bool TryDouble(JsonElement row, string name, out double result) { result = 0; return TryProperty(row, name, out var value) && value.TryGetDouble(out result); } private static string ReadString(JsonElement row, string name) => TryProperty(row, name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : string.Empty; private static bool ReadBool(JsonElement row, string name) => TryProperty(row, name, out var value) && value.ValueKind == JsonValueKind.True; private static IReadOnlyList ReadUInt32Array(JsonElement row, string name) { if (!TryProperty(row, name, out var value) || value.ValueKind != JsonValueKind.Array) return []; return value.EnumerateArray() .Where(x => x.TryGetUInt32(out _)) .Select(x => x.GetUInt32()) .ToArray(); } // Item1: [{ "id1": 36211010, "count": 10 }, ...] - every entry is granted. private static IReadOnlyList<(uint Id, uint Count)> ReadItemList(JsonElement row, string name) { if (!TryProperty(row, name, out var value) || value.ValueKind != JsonValueKind.Array) return []; var result = new List<(uint, uint)>(); foreach (var entry in value.EnumerateArray()) { if (!TryUInt32(entry, "id1", out var id) || id == 0) continue; TryUInt32(entry, "count", out var count); result.Add((id, count == 0 ? 1u : count)); } return result; } // Item2: [{ "id2": .., "min": .., "max": .. }] - one entry is chosen and rolled inside [min, max]. private static IReadOnlyList<(uint Id, uint Min, uint Max)> ReadItemRangeList(JsonElement row, string name) { if (!TryProperty(row, name, out var value) || value.ValueKind != JsonValueKind.Array) return []; var result = new List<(uint, uint, uint)>(); foreach (var entry in value.EnumerateArray()) { if (!TryUInt32(entry, "id2", out var id) || id == 0) continue; TryUInt32(entry, "min", out var min); TryUInt32(entry, "max", out var max); if (max < min) max = min; result.Add((id, min, max)); } return result; } // Item3: [{ "id3": .., "count": 0.00075 }] - 'count' is a relative probability weight. private static IReadOnlyList<(uint Id, double Weight)> ReadItemWeightList(JsonElement row, string name) { if (!TryProperty(row, name, out var value) || value.ValueKind != JsonValueKind.Array) return []; var result = new List<(uint, double)>(); foreach (var entry in value.EnumerateArray()) { if (!TryUInt32(entry, "id3", out var id) || id == 0) continue; TryDouble(entry, "count", out var weight); if (weight <= 0) continue; result.Add((id, weight)); } return result; } // CostCount: [{ "drawCount": 1, "cost": 100 }, ...] private static IReadOnlyList<(uint DrawCount, uint Cost)> ReadCostList(JsonElement row, string name) { if (!TryProperty(row, name, out var value) || value.ValueKind != JsonValueKind.Array) return []; var result = new List<(uint, uint)>(); foreach (var entry in value.EnumerateArray()) { if (!TryUInt32(entry, "drawCount", out var drawCount) || drawCount == 0) continue; TryUInt32(entry, "cost", out var cost); result.Add((drawCount, cost)); } return result; } internal static IReadOnlyCollection GachaTicketItemIds() => Cache.Value.Pools.Values .Select(p => p.MoneyId) .Where(id => id != 0) .Distinct() .OrderBy(id => id) .ToArray(); internal static bool TryConsumableItem(uint id, out ConsumableItem item) => Cache.Value.Items.TryGetValue(id, out item!); internal static bool HasConsumableItems => Cache.Value.Items.Count > 0; internal static IReadOnlyCollection ConsumableItems => Cache.Value.Items.Values; internal static IReadOnlyList SearchConsumableItems(string? query, int limit) { IEnumerable matches = Cache.Value.Items.Values; if (!string.IsNullOrWhiteSpace(query)) { var text = query.Trim(); matches = matches.Where(x => x.Id.ToString().Contains(text, StringComparison.Ordinal) || x.Name.Contains(text, StringComparison.OrdinalIgnoreCase)); } return matches.OrderBy(x => x.Id).Take(limit).ToArray(); } internal sealed record ConsumableItem(uint Id, string Name, uint SubType, uint Quality); }