using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 改装店(Smokey Drift / ApplyCar)的购买链与外观回声 —— /// 2026-09-26 自上游 donor repair-shop.zip 移植(唯一参考源,没有混其它捐赠端)。 /// /// 第一步(进场闭环)已经在 GameRouter.Teleport.cs / GameRouter.Vehicle.cs 落地; /// 这一步补的是「进店之后能干什么」:切换展示车、买件、买套件、清车位。 /// /// ⛑⛑ 三条 donor 用实机日志证明、不看代码绝对猜不出来的结论: /// /// 纯数值件(引擎/刹车数值/传动/车身数值)不能进刷车包。它们在 /// VehiclePartConfig 里没有 PartPath、只有 PartPerfID,混进 Parts 会让真车装配把槽位冲空 /// ⇒ 全黑透明不可驾驶(81005006 + engine 9995002 复现)。但它们是唯一的数值通道 /// (客户端本地不应用),所以 owned-fleet 列表里必须带、刷车包默认不带,用 /// gameplay.repairShop.sendStatParts 做 A/B。 /// Parts 里必须有车轮。只发买过的件 ⇒ 客户端 NPDVehicleSwitch 报 /// 「未配置车轮数据」,车退化成 SwitchToNonPhysicalDriveModular(Burst data not init,透明不可开)。 /// 所以服务端要按套件把 Suit[] + WheelModelA[] + WheelModelB[] + SuitPaint 全展开成底座, /// 再用买过的件按 PartTag 顶替。 /// 钱和库存一律不模拟。客户端在发请求前已经拿我们推下去的商品表自己校验过了, /// 服务端只要 ack ok;扣钱是另一条链(本次不做)。 /// /// internal sealed partial class GameRouter { /// /// 纯数值件:只有 PartPerfID、没有 PartPath(引擎 / 刹车数值 / 传动 / 车身数值)。 /// 见类注释第 1 条 —— 记录并回声进 owned-fleet,但默认排除出刷车包。 /// internal static readonly HashSet StatOnlyPartTags = [13, 20, 21, 22]; /// VehiclePartTagConfig 里「套件」的取值。买到的套件不进 Parts,走 SuitId 底座。 private const uint SuitPartTag = 18u; // ---------------------------------------------------------------------------------- // 外观展开 // ---------------------------------------------------------------------------------- /// /// 一辆车此刻的完整外观: = 套件底座 id, = /// 展开后的零件表。口径与客户端 ExpandForClient 一致(按 PartTag 顶替,没有就追加, /// ConfigId=0 表示移除)。 /// /// 顺序:先按套件展开底座(Suit[] + WheelModelA[] + WheelModelB[] + SuitPaint), /// 再用买过的件按 PartTag 顶替。套件本身(type 18)由 SuitId 承载,不进 Parts。 /// internal static void BuildAppearance(WorldEntryState? world, uint configId, out List parts, out uint suit, bool includeStatParts) { suit = VehicleTuningCatalog4229938.TryGetDefaultSuit(configId, out var defaultSuit) ? defaultSuit : 0; var bought = new List<(uint Type, uint ConfigId)>(); 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 list = new List<(uint Type, uint ConfigId)>(); if (suit != 0 && VehicleTuningCatalog4229938.TryGetSuitRow(suit, out var row)) { foreach (var id in row.Suit.Concat(row.WheelModelA).Concat(row.WheelModelB)) { if (id != 0 && VehicleTuningCatalog4229938.TryGetPartTag(id, out var tag)) list.Add((tag, id)); } if (row.SuitPaint != 0 && VehicleTuningCatalog4229938.TryGetPartTag(row.SuitPaint, out var paintTag)) list.Add((paintTag, row.SuitPaint)); } foreach (var (type, id) in bought) { if (type == SuitPartTag) continue; // 买到的套件由上面的 suit 底座承载 if (!includeStatParts && StatOnlyPartTags.Contains(type)) continue; list.RemoveAll(p => p.Type == type); list.Add((type, id)); } parts = list.Select(p => new GameMethods.VehicleClientPart4229938 { Type = p.Type, ConfigId = p.ConfigId, }).ToList(); } /// /// 购买后重推 SyncAllUnlockedVehicles(64871623):改装面板靠这份列表判断 /// 「哪些类目已解锁」,不重推的话第二次购买就点不动了(donor 2026-09-23 21:19)。 /// /// 与 PublishVehicleOwnership 的区别:那个有「只推一次」的闸门,这个是每次都推。 /// owned-fleet 里带纯数值件(见类注释第 1 条)。 /// internal static async Task PushTunedFleetAsync(TcpSession session, string reason) { if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled) return; var world = GetStateIfExists(session); var fleet = VehicleCatalog4229938.AllSummonable .Select(v => { BuildAppearance(world, v.Id, out var parts, out var suit, includeStatParts: true); return new GameMethods.PlayerVehicleClientDetail4229938 { Id = v.Id, Parts = parts, SuitId = suit, IsPersistent = true, }; }) .ToList(); await session.NotifyAsync(MethodId.SyncAllUnlockedVehicles, UxSerializer.Serialize(fleet), CancellationToken.None); session.Log.Info($"[REPAIRSHOP] fleet re-push reason={reason} count={fleet.Count}"); } // ---------------------------------------------------------------------------------- // 购买链 // ---------------------------------------------------------------------------------- /// 63699253 — 改装面板开面板时要的完整商品表(返回体,不是 notify)。 [Handler(MethodId.AskNpcShopCommodityInfo, HandlerPacketKind.Invoke)] private async Task AskNpcShopCommodityInfo(Connection conn, UxRpcMessage msg) { if (!msg.TryGetArgs(out var args) || args is null || args.shopid == 0) { conn.Log.Warn($"[REPAIRSHOP] commodity-info bad args bytes={msg.Body.Length}"); await conn.ReturnEmptyAsync(msg, 1); return; } if (NpcShopCatalog4229938.TryGetShop(args.shopid, out var shop)) { var rows = new List(shop.Commodities.Count); foreach (var commodity in shop.Commodities) { rows.Add(new GameMethods.NpcShopCommodityInfo4229938 { TemplateId = commodity.TemplateId, Count = commodity.LimitNum > 0 ? commodity.LimitNum : 9999, RefreshTime = 0, Status = 0, Discount = 100, DiscountPrice = commodity.Price, MaxBuyCount = 0, DailyDiscount = 0, CommodityBadgeDiscountDict = new Dictionary(), BlackMarketPriceModifier = 0, }); } await conn.ReturnAsync(msg, new GameMethods.NpcShopCommodityInfoResult4229938 { CurrentDiscount = 100, CommodityInfoList = rows, BuybackCommodityInfoList = [], RefreshState = new GameMethods.ShopRefreshState4229938(), }); conn.Log.Info($"[REPAIRSHOP] commodity-info shop={args.shopid} rows={rows.Count}"); return; } // 不在目录里也要给一个空壳返回:客户端 await 的是 INVOKE,不回就一直挂在那。 await conn.ReturnAsync(msg, new GameMethods.NpcShopCommodityInfoResult4229938 { CurrentDiscount = 100, CommodityInfoList = [], BuybackCommodityInfoList = [], RefreshState = new GameMethods.ShopRefreshState4229938(), }); conn.Log.Info($"[REPAIRSHOP] commodity-info shop={args.shopid} not in catalog -> empty"); } /// /// 63497070 — 店里换展示车。返回新车实体的 uid(0 = 失败),客户端随后 possession 它。 /// /// 落点 = 该店的 Interior(地下工位),不是「玩家旁边」:donor 踩过「spawn 到 /// 玩家右手边 5.5m」导致车卡在墙里。返回之后补一发 SyncTeleportVehicle 把车钉在工位上 /// ——客户端自己的 parking-waypoint 查询在没有 spoon graph 时返回 0,会把它摆在玩家看的地方。 /// [Handler(MethodId.AskVehicleShopSpawnVehicle, HandlerPacketKind.Invoke)] private async Task AskVehicleShopSpawnVehicle(Connection conn, UxRpcMessage msg) { if (!PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled) { await conn.ReturnEmptyAsync(msg, 1); return; } if (!msg.TryGetArgs(out var args) || args is null || args.vehicleId == 0) { conn.Log.Warn($"[REPAIRSHOP] spawn-vehicle bad args bytes={msg.Body.Length}"); await conn.ReturnAsync(msg, new GameMethods.AskVehicleShopSpawnVehicleResult4229938 { vehicleUid = 0 }); return; } // 客户端给的是 NPC 商店 id(94100069/94100070),不是 CarShopConfig id。 var shop = args.shopId switch { 94100070u => ShopInteriors[1], 94100069u => ShopInteriors[0], _ => NearestShop(LastDrivenVehicleFix(conn.Session) is { } f ? new Vec3(f.X, f.Y, f.Z) : null) ?? ShopInteriors[0], }; var spawnPos = new Vec3(shop.Interior.X, shop.Interior.Y, shop.Interior.Z); var result = await SpawnDirectAsync(conn.Session, args.vehicleId, spawnPos, shop.Facing, reason: $"vehicle-shop shop={args.shopId} bind={args.isBind}", exactPos: spawnPos); if (!result.Ok) { conn.Log.Warn($"[REPAIRSHOP] spawn-vehicle rejected: {result.Message}"); await conn.ReturnAsync(msg, new GameMethods.AskVehicleShopSpawnVehicleResult4229938 { vehicleUid = 0 }); return; } await conn.ReturnAsync(msg, new GameMethods.AskVehicleShopSpawnVehicleResult4229938 { vehicleUid = result.EntityId }); await conn.NotifyAsync(MethodId.SyncTeleportVehicle, new SceneMethods.SyncTeleportVehicle4229938 { EntityId = result.EntityId, Position = new SceneMethods.UxVector3(spawnPos.X, spawnPos.Y + 0.15f, spawnPos.Z), Rotation = new SceneMethods.UxVector3(0f, shop.Facing, 0f), Velocity = 0f, Reset = true, MoveToken = 0, }); conn.Log.Info($"[REPAIRSHOP] spawn-vehicle shop={args.shopId} config={args.vehicleId} entity={result.EntityId} " + $"pos=({spawnPos.X:F1},{spawnPos.Y:F1},{spawnPos.Z:F1}) facing={shop.Facing:F1} + snap"); } /// 63613647 — 客户端把停在那的预览车收掉了。只记日志(车是客户端本地预览)。 [Handler(MethodId.AskVehicleShopDestroyParking, HandlerPacketKind.Invoke)] private async Task AskVehicleShopDestroyParking(Connection conn, UxRpcMessage msg) { if (!msg.TryGetArgs(out var args) || args is null) { await conn.ReturnEmptyOkAsync(msg); return; } await conn.ReturnEmptyOkAsync(msg); conn.Log.Info($"[REPAIRSHOP] destroy parking shop={args.shopid}"); } /// 63970007 — 改装面板单件购买。不扣钱不查库存,直接 ok。 [Handler(MethodId.AskBuyCommodity, HandlerPacketKind.Invoke)] private async Task AskBuyCommodity(Connection conn, UxRpcMessage msg) { if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[REPAIRSHOP] buy-commodity bad args bytes={msg.Body.Length}"); await conn.ReturnEmptyAsync(msg, 1); return; } await conn.ReturnEmptyOkAsync(msg); // 房产(House)走的是同一个 AskBuyCommodity —— 客户端 BuyHouseUtils.BuyTheHouse 用 // houseCfg.ShopId / houseCfg.Commodity 调这条,服务端只看到 shopId + commodityId。 // 详情见 GameRouter.House.cs。放在 ReturnEmptyOkAsync 之后:客户端的 // .Callback 会先放「已购买」动画,我们的所有权推送紧接着到,两边不打架。 if (HouseCatalog4229938.IsHouseShop(args.shopid) && HouseCatalog4229938.TryGetByCommodity(args.commodityid, out var house)) { var outcome = await GrantHouseOwnershipAsync(msg.Context.Session, house.Id, conn.Log); conn.Log.Info($"[HOUSE] buy-commodity shop={args.shopid} commodity={args.commodityid} " + $"-> house={house.Id}「{house.Name}」 {outcome} -> ok"); return; } await conn.ReturnEmptyOkAsync(msg); var mapped = VehicleTuningCatalog4229938.TryGetCommodityPart(args.commodityid, out var part) ? $"part={part.PartId} tag={part.PartTag} suit={part.IsSuit}" : "part="; conn.Log.Info($"[REPAIRSHOP] buy-commodity shop={args.shopid} commodity={args.commodityid} count={args.count} {mapped} -> ok"); } /// /// 63701165 — 改装购买(NewCarStoreMgr 的「应用」)。{shopid, vehicleid, /// commodityiddict{商品id:数量}, removedparttypes[]}。 /// /// 这是唯一真正改变服务端状态的一步:把买到的件按车记进 WorldEntryState, /// BuildAppearance 会在后续刷车 / owned-fleet 里把它们回声出去。 /// [Handler(MethodId.AskBuyModifyParts, HandlerPacketKind.Invoke)] private async Task AskBuyModifyParts(Connection conn, UxRpcMessage msg) { if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[REPAIRSHOP] buy-modify bad args bytes={msg.Body.Length}"); await conn.ReturnEmptyAsync(msg, 1); return; } 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(msg.Context.Session); if (world is null || args.vehicleid == 0) { conn.Log.Info($"[REPAIRSHOP] buy-modify shop={args.shopid} vehicle={args.vehicleid} parts=[{parts}] " + $"removed=[{removed}] -> ok (no world state, not recorded)"); return; } var applied = new List(); 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(SuitPartTag)) world.VehicleSuitByConfig.Remove(args.vehicleid); } if (args.commodityiddict is not null) foreach (var kv in args.commodityiddict) { if (!VehicleTuningCatalog4229938.TryGetCommodityPart(kv.Key, out var part)) { applied.Add($"{kv.Key}=?"); continue; } if (part.IsSuit) { world.VehicleSuitByConfig[args.vehicleid] = part.PartId; applied.Add($"{kv.Key}->suit:{part.PartId}"); } else { // 一个槽位只留一条:新车漆顶掉旧车漆。 list.RemoveAll(p => p.Type == part.PartTag); list.Add((part.PartTag, part.PartId)); applied.Add($"{kv.Key}->tag{part.PartTag}:{part.PartId}"); } } world.SessionUnlockedVehicleConfigs.Add(args.vehicleid); } VehicleTuningStore.Save(Profile.PlayerPid, world); await PushTunedFleetAsync(msg.Context.Session, $"tuning-buy vehicle={args.vehicleid}"); conn.Log.Info($"[REPAIRSHOP] buy-modify shop={args.shopid} vehicle={args.vehicleid} parts=[{parts}] " + $"removed=[{removed}] applied=[{string.Join(",", applied)}] -> ok"); } /// /// 63614427 — 4S / 商店整单购买。Smokey Drift 不走这条(它用 AskBuyModifyParts), /// 但客户端会发,不注册就会挂在 await 上,所以照样 ack 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($"[REPAIRSHOP] buy-commodities bad args bytes={msg.Body.Length}"); await conn.ReturnEmptyAsync(msg, 1); return; } var items = args.commodityiddict is null ? "-" : string.Join(",", args.commodityiddict.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}x{kv.Value}")); await conn.ReturnEmptyOkAsync(msg); conn.Log.Info($"[REPAIRSHOP] buy-commodities shop={args.shopid} items=[{items}] -> ok"); } }