Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.VehicleTuning.cs

382 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 改装店(Smokey Drift / ApplyCar)的<b>购买链</b>与外观回声 ——
/// 2026-09-26 自上游 donor <c>repair-shop.zip</c> 移植(唯一参考源,没有混其它捐赠端)。
///
/// <para>第一步(进场闭环)已经在 GameRouter.Teleport.cs / GameRouter.Vehicle.cs 落地;
/// 这一步补的是「进店之后能干什么」:切换展示车、买件、买套件、清车位。</para>
///
/// <para>⛑⛑ 三条 donor 用实机日志证明、不看代码绝对猜不出来的结论:</para>
/// <list type="number">
/// <item><b>纯数值件(引擎/刹车数值/传动/车身数值)不能进刷车包</b>。它们在
/// VehiclePartConfig 里没有 PartPath、只有 PartPerfID,混进 Parts 会让真车装配把槽位冲空
/// ⇒ 全黑透明不可驾驶(81005006 + engine 9995002 复现)。但它们是<b>唯一的数值通道</b>
/// (客户端本地不应用),所以 owned-fleet 列表里必须带、刷车包默认不带,用
/// <c>gameplay.repairShop.sendStatParts</c> 做 A/B。</item>
/// <item><b>Parts 里必须有车轮</b>。只发买过的件 ⇒ 客户端 NPDVehicleSwitch 报
/// 「未配置车轮数据」,车退化成 SwitchToNonPhysicalDriveModular(Burst data not init,透明不可开)。
/// 所以服务端要按套件把 Suit[] + WheelModelA[] + WheelModelB[] + SuitPaint 全展开成底座,
/// 再用买过的件按 PartTag 顶替。</item>
/// <item><b>钱和库存一律不模拟</b>。客户端在发请求前已经拿我们推下去的商品表自己校验过了,
/// 服务端只要 ack ok;扣钱是另一条链(本次不做)。</item>
/// </list>
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// 纯数值件:只有 PartPerfID、没有 PartPath(引擎 / 刹车数值 / 传动 / 车身数值)。
/// 见类注释第 1 条 —— 记录并回声进 owned-fleet,但默认排除出刷车包。
/// </summary>
internal static readonly HashSet<uint> StatOnlyPartTags = [13, 20, 21, 22];
/// <summary>VehiclePartTagConfig 里「套件」的取值。买到的套件不进 Parts,走 SuitId 底座。</summary>
private const uint SuitPartTag = 18u;
// ----------------------------------------------------------------------------------
// 外观展开
// ----------------------------------------------------------------------------------
/// <summary>
/// 一辆车此刻的完整外观:<paramref name="suit"/> = 套件底座 id,<paramref name="parts"/> =
/// 展开后的零件表。口径与客户端 <c>ExpandForClient</c> 一致(按 PartTag 顶替,没有就追加,
/// ConfigId=0 表示移除)。
///
/// 顺序:先按套件展开底座(Suit[] + WheelModelA[] + WheelModelB[] + SuitPaint),
/// 再用买过的件按 PartTag 顶替。套件本身(type 18)由 SuitId 承载,不进 Parts。
/// </summary>
internal static void BuildAppearance(WorldEntryState? world, uint configId,
out List<GameMethods.VehicleClientPart4229938> 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();
}
/// <summary>
/// 购买后重推 <c>SyncAllUnlockedVehicles</c>(64871623):改装面板靠这份列表判断
/// 「哪些类目已解锁」,不重推的话第二次购买就点不动了(donor 2026-09-23 21:19)。
///
/// 与 <c>PublishVehicleOwnership</c> 的区别:那个有「只推一次」的闸门,这个是<b>每次都推</b>。
/// owned-fleet 里<b>带</b>纯数值件(见类注释第 1 条)。
/// </summary>
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}");
}
// ----------------------------------------------------------------------------------
// 购买链
// ----------------------------------------------------------------------------------
/// <summary>63699253 — 改装面板开面板时要的完整商品表(返回体,不是 notify)。</summary>
[Handler(MethodId.AskNpcShopCommodityInfo, HandlerPacketKind.Invoke)]
private async Task AskNpcShopCommodityInfo(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskNpcShopCommodityInfoArgs4229938>(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<GameMethods.NpcShopCommodityInfo4229938>(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<uint, uint>(),
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");
}
/// <summary>
/// 63497070 — 店里换展示车。返回新车实体的 uid(0 = 失败),客户端随后 possession 它。
///
/// 落点 = 该店的 <c>Interior</c>(地下工位),不是「玩家旁边」:donor 踩过「spawn 到
/// 玩家右手边 5.5m」导致车卡在墙里。返回之后补一发 SyncTeleportVehicle 把车钉在工位上
/// ——客户端自己的 parking-waypoint 查询在没有 spoon graph 时返回 0,会把它摆在玩家看的地方。
/// </summary>
[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<GameMethods.AskVehicleShopSpawnVehicleArgs>(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");
}
/// <summary>63613647 — 客户端把停在那的预览车收掉了。只记日志(车是客户端本地预览)。</summary>
[Handler(MethodId.AskVehicleShopDestroyParking, HandlerPacketKind.Invoke)]
private async Task AskVehicleShopDestroyParking(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskVehicleShopDestroyParkingArgs4229938>(out var args) || args is null)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
await conn.ReturnEmptyOkAsync(msg);
conn.Log.Info($"[REPAIRSHOP] destroy parking shop={args.shopid}");
}
/// <summary>63970007 — 改装面板单件购买。不扣钱不查库存,直接 ok。</summary>
[Handler(MethodId.AskBuyCommodity, HandlerPacketKind.Invoke)]
private async Task AskBuyCommodity(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskBuyCommodityArgs4229938>(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=<unmapped>";
conn.Log.Info($"[REPAIRSHOP] buy-commodity shop={args.shopid} commodity={args.commodityid} count={args.count} {mapped} -> ok");
}
/// <summary>
/// 63701165 — 改装购买(NewCarStoreMgr 的「应用」)。{shopid, vehicleid,
/// commodityiddict{商品id:数量}, removedparttypes[]}。
///
/// 这是<b>唯一真正改变服务端状态</b>的一步:把买到的件按车记进 WorldEntryState,
/// BuildAppearance 会在后续刷车 / owned-fleet 里把它们回声出去。
/// </summary>
[Handler(MethodId.AskBuyModifyParts, HandlerPacketKind.Invoke)]
private async Task AskBuyModifyParts(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskBuyModifyPartsArgs4229938>(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<string>();
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");
}
/// <summary>
/// 63614427 — 4S / 商店整单购买。Smokey Drift 不走这条(它用 AskBuyModifyParts),
/// 但客户端会发,不注册就会挂在 await 上,所以照样 ack ok 并记日志。
/// </summary>
[Handler(MethodId.AskBuyCommodities, HandlerPacketKind.Invoke)]
private async Task AskBuyCommodities(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskBuyCommoditiesArgs4229938>(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");
}
}