608 lines
27 KiB
C#
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;
|
|
}
|