using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
///
/// Client economy tables (backpack items, 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!);
///
/// Backpack items this build actually ships (ConsumableConfig). The client resolves a pack
/// item's name, icon and bag page through the same table and silently drops rows it cannot find
/// (CommonItemManager_Package.SetPackItem), so a grant has to be checked against it before it is
/// pushed; item ids are 36xxxxxx and never overlap weapons (98xxxxxx), fashion (111xxxxx),
/// suits (1119xxxx) or vehicles (81xxxxxx).
///
internal static bool TryConsumableItem(uint id, out ConsumableItem item)
=> Cache.Value.Items.TryGetValue(id, out item!);
/// False when the table was not imported; grants then fall back to no validation.
internal static bool HasConsumableItems => Cache.Value.Items.Count > 0;
internal static IReadOnlyCollection ConsumableItems => Cache.Value.Items.Values;
/// Id-or-name lookup for the debug panel: matches on any id digit and on a name substring.
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 static IReadOnlyList PoolContents(uint poolId)
=> Cache.Value.ContentsByPool.TryGetValue(poolId, out var contents) ? contents : [];
///
/// Wardrobe pieces a fashion suit is made of (FashionSuitConfig.FashionIdList). The client
/// answers "do I own this suit?" by asking about every piece (MallManager.CheckFashionSuitOwned),
/// so both the shelf check and the purchase need the suit -> pieces mapping.
///
internal static bool TryFashionSuit(uint suitId, out uint[] fashionIds)
=> Cache.Value.FashionSuits.TryGetValue(suitId, out fashionIds!);
///
/// Wardrobe pieces the direct-sale shelves stock. They have to stay locked in the login payload:
/// their commodities are authored OwnedCanNotBuy, and MallManager.IsCommodityBuyable() reads
/// that flag as "hide the button once the player owns it" - a free unlock therefore greys the row
/// out permanently instead of selling it.
///
internal static IReadOnlySet MallSoldFashionIds => Cache.Value.MallSoldFashions;
/// Vehicle counterpart of (ApplyCarManager.CheckPlayerAlreadyHasVehicle).
internal static IReadOnlySet MallSoldVehicleIds => Cache.Value.MallSoldVehicles;
// MallCommodityConfig.Type: 0 stocks a wardrobe suit, 1 a vehicle, 2 a fashion suit that retail
// only trades through the order book, 5/6/7 plain item rows.
internal const uint CommodityTypeFashion4229938 = 0u;
internal const uint CommodityTypeVehicle4229938 = 1u;
// The direct-sale group of MallConfig (Tab.Main = 2): sub-tabs Outfits (2/1), Vehicles (2/3),
// 14 and 15. Only these shelves are held back from the free unlock; every other tab keeps
// shipping unlocked goods because nothing there is sold on ownership.
private static readonly uint[] DirectSaleMallIds4229938 = [2u, 3u, 14u, 15u];
/// One backpack item row. SubType selects the client's bag page via ConsumableTypeConfig.
internal sealed record ConsumableItem(uint Id, string Name, uint SubType, uint Quality);
/// 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;
///
/// Suit ids the client derives ownership from: MallManager.GetCommodityBindId() picks one entry
/// of CommodityBindId (index 2 for male spirits, index 1 otherwise), so the whole list has
/// to be honoured to cover both sexes. The row's own BindId is deliberately left out - the
/// client never consults it for ownership.
///
internal IEnumerable OwnershipBindIds
{
get
{
foreach (var bindId in CommodityBindIds)
{
if (bindId != 0)
yield return bindId;
}
}
}
}
///
/// 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 Items { get; init; } = [];
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 FashionSuits { get; init; } = [];
internal IReadOnlySet MallSoldFashions { get; init; } = new HashSet();
internal IReadOnlySet MallSoldVehicles { get; init; } = new HashSet();
}
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 items = new Dictionary();
foreach (var row in ReadRecords(Path.Combine(root, "ConsumableConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var itemId) || itemId == 0)
continue;
TryUInt32(row, "SubType", out var subType);
TryUInt32(row, "Quality", out var quality);
items[itemId] = new ConsumableItem(itemId, ReadString(row, "Name"), subType, quality);
}
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"));
}
var fashionSuits = new Dictionary();
foreach (var row in ReadRecords(Path.Combine(root, "FashionSuitConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
var pieces = ReadUInt32Array(row, "FashionIdList").Where(x => x != 0).Distinct().ToArray();
if (pieces.Length > 0)
fashionSuits[id] = pieces;
}
// Everything the direct-sale shelves stock has to stay unowned at login, otherwise the client
// hides the buy button behind its own "already owned" rule. See MallSoldFashionIds.
var mallSoldFashions = new HashSet();
var mallSoldVehicles = new HashSet();
foreach (var commodity in mall.Values)
{
if (Array.IndexOf(DirectSaleMallIds4229938, commodity.MallId) < 0)
continue;
foreach (var bindId in commodity.OwnershipBindIds)
{
if (commodity.Type == CommodityTypeFashion4229938
&& fashionSuits.TryGetValue(bindId, out var pieces))
{
mallSoldFashions.UnionWith(pieces);
}
else if (commodity.Type == CommodityTypeVehicle4229938)
{
mallSoldVehicles.Add(bindId);
}
}
}
foreach (var message in warnings)
Console.WriteLine($"[ECONOMY] {message}");
Console.WriteLine(
$"[ECONOMY] items={items.Count} mall={mall.Count} drops={drops.Count} pools={pools.Count} " +
$"gachaContents={contentsByPool.Values.Sum(x => x.Count)} tierRules={tierRules.Count} " +
$"tradeItems={tradeItems.Count} fashionSuits={fashionSuits.Count} " +
$"directSaleFashions={mallSoldFashions.Count} directSaleVehicles={mallSoldVehicles.Count} " +
$"source={root}");
return new Data
{
Items = items,
Mall = mall,
Drops = drops,
Pools = pools,
TierRules = tierRules,
ContentsByPool = contentsByPool,
TradeItems = tradeItems,
FashionSuits = fashionSuits,
MallSoldFashions = mallSoldFashions,
MallSoldVehicles = mallSoldVehicles,
};
}
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 bool ReadBool(JsonElement row, string name)
=> TryProperty(row, name, out var value) && value.ValueKind == JsonValueKind.True;
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 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;
}
}