Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.Shop.cs
T

608 lines
27 KiB
C#

using System.Text.Json;
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// NPC shop / vehicle 4S dealer / tuning system, ported from the PrivateServer1 (Drmk)
/// share pack (battle-tested against build 4229938). The client renders exactly the
/// inventory the server pushes via SyncFullCommodityInfos; purchases are recorded
/// server-side per vehicle config and echoed back in spawns + the owned-vehicle list.
/// </summary>
internal sealed partial class GameRouter
{
private static readonly Lazy<Dictionary<uint, List<uint>>> ShopCommodityCache = new(LoadShopCommodities);
/// <summary>commodityId → (partId, partTag, isSuit), chained like the client's
/// GenCommodityItem: ShopCommodityConfig.BindId → ConsumableConfig.BindId →
/// VehiclePartConfig.PartTag, or VehiclePartSuitConfig for body kits.</summary>
private static readonly Lazy<Dictionary<uint, (uint PartId, uint PartTag, bool IsSuit)>> CommodityPartCache = new(LoadCommodityParts);
/// <summary>vehicleConfigId → DefaultSuitId (VehicleConfig) — the appearance BASE.</summary>
private static readonly Lazy<Dictionary<uint, uint>> DefaultSuitCache = new(LoadDefaultSuits);
/// <summary>partId → PartTag (VehiclePartConfig).</summary>
private static readonly Lazy<Dictionary<uint, uint>> PartTagCache = new(LoadPartTags);
/// <summary>suitId → suit part list (VehiclePartSuitConfig.Suit array).</summary>
private static readonly Lazy<Dictionary<uint, List<uint>>> SuitRowsCache = new(LoadSuitRows);
private const uint ContinentalShopId = 94100019u;
private static readonly Vec3 ContinentalShopCreatePosition = new(3607.90991f, -0.1f, 2353.90991f);
private const float ContinentalShopCreateFacing = -31.1864891f;
private static readonly Dictionary<uint, uint> ContinentalVehicleCommodities = new()
{
[94201018u] = 81005002u,
[94201034u] = 81005005u,
[94201054u] = 81005007u,
};
/// <summary>The two authored repair shops: Entrance = street door marker,
/// Interior/Facing = parking spot inside the garage (west / seaside).</summary>
internal static readonly (SceneMethods.UxVector3 Entrance, SceneMethods.UxVector3 Interior, float Facing)[] ShopInteriors =
[
(new(1439.10803f, 1.5f, 2379.6001f), new(1443.23f, -19.992f, 2351.428f), 180f), // west
(new(2791.15601f, 1.37899995f, 1286.39001f), new(2783.912f, -11.92f, 1262.419f), 0f), // seaside
];
private static readonly uint[] ExtraTuningCommodities =
[
// Default suit commodities for the 94100019 vehicles — without these
// NewCarStoreMgr has no PartTag=18 mapping and logs "no valid suit".
94220759u, 94220761u,
94220353u, 94220354u, 94220468u, 94220355u, 94220356u, 94220357u, 94220358u,
94220771u, 94220772u, 94220773u, 94220774u, 94220775u, 94220776u, 94220777u,
94220778u, 94220779u, 94220780u, 94220781u, 94220782u, 94220783u, 94220784u,
94220785u,
];
[Handler(MethodId.AskNpcShop, HandlerPacketKind.Invoke)]
private async Task AskNpcShop(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskNpcShop>(out var args) || args is null)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
// ORDER MATTERS: push BEFORE the ack — the client's callback reads the shop
// dict synchronously on ack; pushing after leaves every tuning tab locked.
ShopCommodityCache.Value.TryGetValue(args.shopid, out var ids);
var allIds = (ids ?? []).Concat(ExtraTuningCommodities).Distinct().ToList();
var items = allIds.Select(id => new GameMethods.ShopCommodityItem
{
TemplateId = id,
Count = 1,
Status = 0,
Discount = 100,
MaxBuyCount = 0,
}).ToList();
await conn.Session.NotifyAsync(MethodId.IGameToClient_SyncFullCommodityInfos, UxSerializer.Serialize(
new GameMethods.SyncFullCommodityInfos
{
shopId = args.shopid,
infos = items,
}), CancellationToken.None);
await conn.ReturnEmptyOkAsync(msg);
conn.Log.Info($"[SHOP] npc shop={args.shopid} pushed {items.Count} commodities (before ack)");
}
[Handler(MethodId.AskNpcShopCommodityInfo, HandlerPacketKind.Invoke)]
private async Task AskNpcShopCommodityInfo(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskNpcShopCommodityInfo>(out var args) || args is null)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
ShopCommodityCache.Value.TryGetValue(args.shopid, out var ids);
var items = (ids ?? []).Select(id => new GameMethods.ShopCommodityItem
{
TemplateId = id,
Count = 1,
Status = 0,
Discount = 100,
MaxBuyCount = 0,
}).ToList();
await conn.ReturnAsync(msg, new GameMethods.NpcShopCommodityInfo
{
CurrentDiscount = 100,
CommodityInfoList = items,
});
conn.Log.Info($"[SHOP] commodity info shop={args.shopid} items={items.Count}");
}
[Handler(MethodId.AskVehicleShopSpawnVehicle, HandlerPacketKind.Invoke)]
private async Task AskVehicleShopSpawnVehicle(Connection conn, UxRpcMessage msg)
{
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
if (!msg.TryGetArgs<GameMethods.AskVehicleShopSpawnVehicle>(out var args) || args is null || args.vehicleid == 0)
{
conn.Log.Warn($"[SHOP] spawn-vehicle bad args bytes={msg.Body.Length}");
await conn.ReturnEmptyOkAsync(msg);
return;
}
var state = GetStateIfExists(conn.Session);
var shop = args.shopid switch
{
94100070u => ShopInteriors[1],
94100069u => ShopInteriors[0],
_ => NearestShopStruct(state is { Ready: true, HasLastReportedPlayerTransform: true }
? new Vec3(state.LastReportedPlayerPosition.X, state.LastReportedPlayerPosition.Y, state.LastReportedPlayerPosition.Z)
: null) ?? ShopInteriors[0],
};
var continental = args.shopid == ContinentalShopId;
var spawnPos = continental
? ContinentalShopCreatePosition
: new Vec3(shop.Interior.X, shop.Interior.Y, shop.Interior.Z);
var facing = continental ? ContinentalShopCreateFacing : shop.Facing;
var result = await SpawnDirectAsync(
conn.Session, args.vehicleid, spawnPos, facing, rightOffset: 0f, sourceType: 0,
reason: $"vehicle-shop shop={args.shopid} bind={args.isbind}{(continental ? " continental" : "")}", pushSummonStart: true);
if (!result.Ok)
{
conn.Log.Warn($"[SHOP] spawn-vehicle rejected: {result.Message}");
await conn.ReturnAsync(msg, new GameMethods.AskVehicleShopSpawnVehicleResult { vehicleUid = 0 });
return;
}
await conn.ReturnAsync(msg, new GameMethods.AskVehicleShopSpawnVehicleResult { vehicleUid = result.EntityId });
// Snap the freshly-spawned car onto the parking spot explicitly (the client's own
// parking-waypoint lookup can land it "in the wall where you look").
await conn.Session.NotifyAsync(MethodId.SyncTeleportVehicle, UxSerializer.Serialize(
new SceneMethods.SyncTeleportVehicle
{
EntityId = result.EntityId,
Position = new SceneMethods.UxVector3(spawnPos.X, spawnPos.Y, spawnPos.Z),
Rotation = new SceneMethods.UxVector3(0f, facing, 0f),
Velocity = 0f,
Reset = true,
MoveToken = 0,
}), CancellationToken.None);
conn.Log.Info($"[SHOP] spawn-vehicle shop={args.shopid} config={args.vehicleid} entity={result.EntityId} pos=({spawnPos.X:F1},{spawnPos.Y:F1},{spawnPos.Z:F1}){(continental ? " continental" : "")}");
}
[Handler(MethodId.AskBuyModifyParts, HandlerPacketKind.Invoke)]
private async Task AskBuyModifyParts(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskBuyModifyParts>(out var args) || args is null)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
// Tuning purchase: money/limits are not simulated — ack ok; the client applies
// the parts locally, the server records them so spawns + the owned list echo them.
var parts = args.commodityiddict is null ? "-" : string.Join(",", args.commodityiddict.Select(kv => kv.Key + "x" + kv.Value));
var removed = args.removedparttypes is null ? "-" : string.Join(",", args.removedparttypes);
await conn.ReturnEmptyOkAsync(msg);
var world = GetStateIfExists(conn.Session);
if (world is not null && args.vehicleid != 0)
{
lock (world.SyncRoot)
{
if (!world.VehiclePartsByConfig.TryGetValue(args.vehicleid, out var list))
world.VehiclePartsByConfig[args.vehicleid] = list = new();
if (args.removedparttypes is { Count: > 0 })
{
list.RemoveAll(p => args.removedparttypes.Contains(p.Type));
if (args.removedparttypes.Contains(18))
world.VehicleSuitByConfig.Remove(args.vehicleid);
}
if (args.commodityiddict is not null)
foreach (var kv in args.commodityiddict)
{
if (!CommodityPartCache.Value.TryGetValue(kv.Key, out var part))
continue;
if (part.IsSuit)
world.VehicleSuitByConfig[args.vehicleid] = part.PartId;
else
{
list.RemoveAll(p => p.Type == part.PartTag);
list.Add((part.PartTag, part.PartId));
}
}
}
VehicleTuningStore.Save(Profile.PlayerPid, world);
await PushUnlockedVehiclesAsync(conn.Session, $"tuning-buy vehicle={args.vehicleid}");
}
conn.Log.Info($"[SHOP] buy-modify shop={args.shopid} vehicle={args.vehicleid} parts=[{parts}] removed=[{removed}] -> ok");
}
[Handler(MethodId.AskBuyCommodities, HandlerPacketKind.Invoke)]
private async Task AskBuyCommodities(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskBuyCommodities>(out var args) || args is null)
{
conn.Log.Warn($"[SHOP] buy-commodities bad args bytes={msg.Body.Length}");
await conn.ReturnEmptyOkAsync(msg);
return;
}
var items = args.commodityiddict is null
? "-"
: string.Join(",", args.commodityiddict.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}x{kv.Value}"));
var vehicleConfigId = 0u;
if (args.shopid == ContinentalShopId && args.commodityiddict is { } commodityIds)
{
foreach (var commodityId in commodityIds.Keys)
{
if (ContinentalVehicleCommodities.TryGetValue(commodityId, out var configId))
{
vehicleConfigId = configId;
break;
}
}
}
conn.Log.Info($"[SHOP] buy-commodities shop={args.shopid} items=[{items}] vehicle={vehicleConfigId} -> ok{(args.shopid == ContinentalShopId ? " (4S)" : "")}");
if (vehicleConfigId != 0)
{
var world = GetStateIfExists(conn.Session);
if (world is not null)
{
var changed = false;
lock (world.SyncRoot)
{
changed |= world.SessionUnlockedVehicleConfigs.Add(vehicleConfigId);
if (args.commodityiddict is { } purchased)
{
foreach (var kv in purchased)
{
if (!CommodityPartCache.Value.TryGetValue(kv.Key, out var part))
continue;
if (part.IsSuit)
{
if (world.VehicleSuitByConfig.GetValueOrDefault(vehicleConfigId) != part.PartId)
{
world.VehicleSuitByConfig[vehicleConfigId] = part.PartId;
changed = true;
}
}
else
{
if (!world.VehiclePartsByConfig.TryGetValue(vehicleConfigId, out var list))
world.VehiclePartsByConfig[vehicleConfigId] = list = new();
list.RemoveAll(p => p.Type == part.PartTag);
list.Add((part.PartTag, part.PartId));
changed = true;
}
}
}
}
if (changed)
await PushUnlockedVehiclesAsync(conn.Session, $"4S-buy vehicle={vehicleConfigId}");
}
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskBuyCommodity, HandlerPacketKind.Invoke)]
private Task AskBuyCommodity(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskBuyCommodity>();
conn.Log.Info($"[SHOP] buy commodity={args.commodityid} shop={args.shopid} count={args.count} -> ok");
return conn.ReturnEmptyOkAsync(msg);
}
/// <summary>AskReadCommodities: same inventory push as AskNpcShop — the client renders
/// what the server pushed. Body: {shopid:u32}.</summary>
[Handler(MethodId.AskReadCommodities, HandlerPacketKind.Invoke)]
private async Task AskReadCommodities(Connection conn, UxRpcMessage msg)
{
var shopId = FirstUInt32(msg.Body);
ShopCommodityCache.Value.TryGetValue(shopId, out var ids);
var items = (ids ?? []).Select(id => new GameMethods.ShopCommodityItem
{
TemplateId = id,
Count = 1,
Status = 0,
Discount = 100,
MaxBuyCount = 0,
}).ToList();
await conn.Session.NotifyAsync(MethodId.IGameToClient_SyncFullCommodityInfos, UxSerializer.Serialize(
new GameMethods.SyncFullCommodityInfos
{
shopId = shopId,
infos = items,
}), CancellationToken.None);
await conn.ReturnEmptyOkAsync(msg);
conn.Log.Info($"[SHOP] read commodities shop={shopId} items={items.Count}");
}
/// <summary>Bag purchases: sandbox-unlimited economy — ack and let the client add to bag.</summary>
[Handler(MethodId.AskBuyCommodityToBag, HandlerPacketKind.Invoke)]
private Task AskBuyCommodityToBag(Connection conn, UxRpcMessage msg)
{
conn.Log.Info($"[SHOP] buy-to-bag bytes={msg.Body.Length}");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskBuyCommoditiesToBag, HandlerPacketKind.Invoke)]
private Task AskBuyCommoditiesToBag(Connection conn, UxRpcMessage msg)
{
conn.Log.Info($"[SHOP] buy-commodities-to-bag bytes={msg.Body.Length}");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskVehicleShopDestroyParking, HandlerPacketKind.Invoke)]
private Task AskVehicleShopDestroyParking(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskVehicleShopDestroyParking>();
conn.Log.Info($"[SHOP] destroy parking shop={args.shopid}");
return conn.ReturnEmptyOkAsync(msg);
}
/// <summary>Push the owned-vehicle list (with appearance overrides) to the session.</summary>
internal static async Task PushUnlockedVehiclesAsync(TcpSession session, string reason)
{
var world = GetStateIfExists(session);
var result = BuildUnlockedResult(world);
await session.NotifyAsync(MethodId.SyncAllUnlockedVehicles, UxSerializer.Serialize(result), CancellationToken.None);
session.Log.Info($"[VEHICLE] unlocked list re-pushed ({reason}) count={result.Vehicles.Count}");
}
/// <summary>Appearance = default suit (base) + bought part overrides merged by PartTag.</summary>
internal static SceneMethods.VehicleClientPart[] BuildAppearance(WorldEntryState? world, uint configId, out uint suit, bool includeStatParts)
{
suit = DefaultSuitCache.Value.TryGetValue(configId, out var ds) ? ds : 0u;
var bought = new List<(uint Type, uint PartId)>();
if (world is not null)
{
lock (world.SyncRoot)
{
if (world.VehiclePartsByConfig.TryGetValue(configId, out var remembered))
bought.AddRange(remembered);
if (world.VehicleSuitByConfig.TryGetValue(configId, out var boughtSuit))
suit = boughtSuit;
}
}
var tags = PartTagCache.Value;
var list = new List<(uint Type, uint PartId)>();
if (suit != 0 && SuitRowsCache.Value.TryGetValue(suit, out var suitParts))
{
foreach (var id in suitParts)
{
if (id != 0 && tags.TryGetValue(id, out var tag))
list.Add((tag, id));
}
}
foreach (var (type, id) in bought)
{
if (type == 18)
continue; // bought suit is applied via the SuitId base above
if (!includeStatParts && type is 13 or 20 or 21 or 22)
continue; // stat-only tags never reach the visual echo
list.RemoveAll(p => p.Type == type);
list.Add((type, id));
}
return list.Select(p => new SceneMethods.VehicleClientPart { Type = p.Type, ConfigId = p.PartId }).ToArray();
}
/// <summary>BuildUnlockedResult with per-vehicle appearance overrides + session unlocks.</summary>
internal static SceneMethods.AskGetUnlockedVehiclesResult BuildUnlockedResult(WorldEntryState? world)
{
var fleet = PrivateServerConfigStore.Current.Gameplay.Vehicles.FleetIds.ToList();
if (world is not null)
{
lock (world.SyncRoot)
fleet.AddRange(world.SessionUnlockedVehicleConfigs);
}
return new SceneMethods.AskGetUnlockedVehiclesResult
{
Vehicles = fleet.Distinct().Select(id =>
{
var parts = BuildAppearance(world, id, out var suit, includeStatParts: true);
return new SceneMethods.PlayerVehicleClientDetail
{
Id = id,
Parts = parts.ToList(),
SuitId = suit,
IsPersistent = true,
};
}).ToList(),
};
}
/// <summary>Pick the shop whose street entrance is nearest the given position.</summary>
internal static SceneMethods.UxVector3? NearestShop(Vec3? playerPos)
=> NearestShopStruct(playerPos)?.Entrance;
private static (SceneMethods.UxVector3 Entrance, SceneMethods.UxVector3 Interior, float Facing)? NearestShopStruct(Vec3? playerPos)
{
if (playerPos is null) return null;
var best = ShopInteriors[0];
var bestD2 = float.MaxValue;
foreach (var s in ShopInteriors)
{
var dx = s.Entrance.X - playerPos.Value.X;
var dz = s.Entrance.Z - playerPos.Value.Z;
var d2 = dx * dx + dz * dz;
if (d2 < bestD2) { bestD2 = d2; best = s; }
}
return best;
}
private static string? FindDumpDir()
{
try
{
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
return Directory.Exists(root) ? root : null;
}
catch
{
return null;
}
}
private static Dictionary<uint, List<uint>> LoadShopCommodities()
{
var result = new Dictionary<uint, List<uint>>();
var root = FindDumpDir();
if (root is null) return result;
try
{
var shops = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "ShopConfig.json"))).RootElement.GetProperty("records");
var groups = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "ShopCommodityGroupConfig.json"))).RootElement.GetProperty("records");
var groupIds = new Dictionary<uint, List<uint>>();
foreach (var g in groups.EnumerateArray())
{
var gid = g.GetProperty("Id").GetUInt32();
var list = new List<uint>();
if (g.TryGetProperty("CommodityIDList", out var arr) && arr.ValueKind == JsonValueKind.Array)
foreach (var c in arr.EnumerateArray())
list.Add(c.GetUInt32());
groupIds[gid] = list;
}
foreach (var s in shops.EnumerateArray())
{
var sid = s.GetProperty("Id").GetUInt32();
var merged = new List<uint>();
if (s.TryGetProperty("CommodityGroupIdList", out var garr) && garr.ValueKind == JsonValueKind.Array)
foreach (var gidNode in garr.EnumerateArray())
if (groupIds.TryGetValue(gidNode.GetUInt32(), out var list))
merged.AddRange(list);
if (merged.Count > 0)
result[sid] = merged;
}
Console.WriteLine($"[SHOP] shop commodity map ready: {result.Count} shops.");
}
catch (Exception ex)
{
Console.WriteLine($"[SHOP] shop table load failed: {ex.Message}");
}
return result;
}
private static Dictionary<uint, (uint PartId, uint PartTag, bool IsSuit)> LoadCommodityParts()
{
var result = new Dictionary<uint, (uint, uint, bool)>();
var root = FindDumpDir();
if (root is null) return result;
try
{
uint U(JsonElement e, string name) => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetUInt32() : 0;
var consumables = new Dictionary<uint, uint>();
foreach (var c in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "ConsumableConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
{
var id = U(c, "LT.ConfigGen.ICommonItemConfig.Id");
if (id != 0)
consumables[id] = U(c, "LT.ConfigGen.ICommonItemConfig.BindId");
}
var tags = new Dictionary<uint, uint>();
foreach (var p in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "VehiclePartConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
{
var id = U(p, "Id");
if (id != 0)
tags[id] = U(p, "PartTag");
}
var suits = new HashSet<uint>();
foreach (var s in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "VehiclePartSuitConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
{
var id = U(s, "Id");
if (id != 0)
suits.Add(id);
}
foreach (var s in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "ShopCommodityConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
{
var cid = U(s, "Id");
if (cid == 0 || !consumables.TryGetValue(U(s, "BindId"), out var partId) || partId == 0)
continue;
if (tags.TryGetValue(partId, out var tag))
result[cid] = (partId, tag, false);
else if (suits.Contains(partId))
result[cid] = (partId, 18, true);
}
Console.WriteLine($"[SHOP] commodity-part map ready: {result.Count} commodities.");
}
catch (Exception ex)
{
Console.WriteLine($"[SHOP] commodity-part map load failed: {ex.Message}");
}
return result;
}
private static Dictionary<uint, uint> LoadDefaultSuits()
{
var result = new Dictionary<uint, uint>();
var root = FindDumpDir();
if (root is null) return result;
try
{
foreach (var row in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "VehicleConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
{
if (!row.TryGetProperty("Id", out var idNode) || !idNode.TryGetUInt32(out var id) || id == 0)
continue;
if (row.TryGetProperty("DefaultSuitId", out var s) && s.TryGetUInt32(out var suit) && suit != 0)
result[id] = suit;
}
}
catch (Exception ex)
{
Console.WriteLine($"[SHOP] default-suit map load failed: {ex.Message}");
}
return result;
}
private static Dictionary<uint, uint> LoadPartTags()
{
var result = new Dictionary<uint, uint>();
var root = FindDumpDir();
if (root is null) return result;
try
{
foreach (var p in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "VehiclePartConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
{
var id = U32(p, "Id");
if (id != 0)
result[id] = U32(p, "PartTag");
}
}
catch (Exception ex)
{
Console.WriteLine($"[SHOP] part-tag map load failed: {ex.Message}");
}
return result;
}
private static Dictionary<uint, List<uint>> LoadSuitRows()
{
var result = new Dictionary<uint, List<uint>>();
var root = FindDumpDir();
if (root is null) return result;
try
{
foreach (var s in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "VehiclePartSuitConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
{
var id = U32(s, "Id");
if (id == 0 || !s.TryGetProperty("Suit", out var arr) || arr.ValueKind != JsonValueKind.Array)
continue;
var parts = new List<uint>();
foreach (var p in arr.EnumerateArray())
parts.Add(p.ValueKind == JsonValueKind.Number ? p.GetUInt32() : 0u);
result[id] = parts;
}
}
catch (Exception ex)
{
Console.WriteLine($"[SHOP] suit rows load failed: {ex.Message}");
}
return result;
}
private static uint U32(JsonElement e, string name)
=> e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetUInt32() : 0u;
}