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; /// /// 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. /// internal sealed partial class GameRouter { private static readonly Lazy>> ShopCommodityCache = new(LoadShopCommodities); /// commodityId → (partId, partTag, isSuit), chained like the client's /// GenCommodityItem: ShopCommodityConfig.BindId → ConsumableConfig.BindId → /// VehiclePartConfig.PartTag, or VehiclePartSuitConfig for body kits. private static readonly Lazy> CommodityPartCache = new(LoadCommodityParts); /// vehicleConfigId → DefaultSuitId (VehicleConfig) — the appearance BASE. private static readonly Lazy> DefaultSuitCache = new(LoadDefaultSuits); /// partId → PartTag (VehiclePartConfig). private static readonly Lazy> PartTagCache = new(LoadPartTags); /// suitId → suit part list (VehiclePartSuitConfig.Suit array). private static readonly Lazy>> 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 ContinentalVehicleCommodities = new() { [94201018u] = 81005002u, [94201034u] = 81005005u, [94201054u] = 81005007u, }; /// The two authored repair shops: Entrance = street door marker, /// Interior/Facing = parking spot inside the garage (west / seaside). 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(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(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(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(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(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(); conn.Log.Info($"[SHOP] buy commodity={args.commodityid} shop={args.shopid} count={args.count} -> ok"); return conn.ReturnEmptyOkAsync(msg); } /// AskReadCommodities: same inventory push as AskNpcShop — the client renders /// what the server pushed. Body: {shopid:u32}. [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}"); } /// Bag purchases: sandbox-unlimited economy — ack and let the client add to bag. [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(); conn.Log.Info($"[SHOP] destroy parking shop={args.shopid}"); return conn.ReturnEmptyOkAsync(msg); } /// Push the owned-vehicle list (with appearance overrides) to the session. 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}"); } /// Appearance = default suit (base) + bought part overrides merged by PartTag. 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(); } /// BuildUnlockedResult with per-vehicle appearance overrides + session unlocks. 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(), }; } /// Pick the shop whose street entrance is nearest the given position. 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> LoadShopCommodities() { var result = new Dictionary>(); 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>(); foreach (var g in groups.EnumerateArray()) { var gid = g.GetProperty("Id").GetUInt32(); var list = new List(); 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(); 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 LoadCommodityParts() { var result = new Dictionary(); 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(); 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(); 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(); 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 LoadDefaultSuits() { var result = new Dictionary(); 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 LoadPartTags() { var result = new Dictionary(); 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> LoadSuitRows() { var result = new Dictionary>(); 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(); 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; }