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");
}
}