382 lines
19 KiB
C#
382 lines
19 KiB
C#
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");
|
||
}
|
||
}
|