820 lines
38 KiB
C#
820 lines
38 KiB
C#
using System.Buffers.Binary;
|
||
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;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// Cat Express (蓝猫物流) — the courier app, board + take-or-drop half.
|
||
///
|
||
/// Why this file exists: the app already opens (the icon is published with every other phone app and
|
||
/// SystemUnlock 305 is unlocked), but every list inside it is a server reply. With no handler each
|
||
/// request fell through to the generic typed default, so the board stayed empty and the row was
|
||
/// never takeable — the whole "接单 -> 详情 -> 支援" loop had nothing to iterate over.
|
||
///
|
||
/// Scope of this first pass — exactly what its client (*DeliveryHomepagePanelStore*) asks for:
|
||
/// · take-order board with per-row accept countdown <c>AskGetTruckJobOrders</c> / <c>AskRefreshTruckOrder</c>
|
||
/// · taking and dropping a row <c>AskAcceptTruckJobOrder</c> / <c>AskObsoleteTruckJobOrder</c>
|
||
/// · the finished-log page <c>AskGetFinishedOrderWraps</c>
|
||
/// · the account-level satisfaction average <c>AskGetTruckSatisfactionAverage</c>
|
||
/// · the two support buttons rendered by <c>RefreshCallFunctionListView</c>
|
||
/// – 叫车 already works: the panel calls the client-side <c>AskSummonVehicle</c>(67012447),
|
||
/// which has had a server handler since before this file existed (GameRouter.Vehicle.cs:449),
|
||
/// so all that is missing here is remembering the chosen vehicle
|
||
/// (<c>AskSetTruckJobDefaultVehicleId</c>). UberSimOrderSupportConfig.CallCarCfg ships
|
||
/// vehicleId [81003022, 81004024] and the panel falls back to that list, so calling a car
|
||
/// also works before anything is chosen.
|
||
/// – 无人机 (Drone) needs buff 52800661 on the player, otherwise the button is filtered out
|
||
/// by RefreshCallFunctionListView. It is published by
|
||
/// <c>gameplay.deliverySupportBuffIds</c> through CharacterCapabilities.
|
||
/// Settlement (PreSettle / Settle / the score row in the finished list) is NOT implemented: it needs
|
||
/// the cargo-integrity model and its drop table, and the board is worth shipping without it.
|
||
///
|
||
/// ⛑ Every field below is transcribed from the client's own writer
|
||
/// (RPCSerializeAuto.lua lines cited in TruckOrderMethods4229938.cs). Positions are all zero: the
|
||
/// server has no authored pickup/delivery coordinates, and inventing ones would put the map pin in
|
||
/// the wrong place — an honest zero is better than a wrong coordinate.
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
private const string TruckBoardKey = "client4229938-truck-board";
|
||
private const string TruckAutoAcceptKey = "client4229938-truck-autoaccept";
|
||
private const string TruckDefaultVehicleKey = "client4229938-truck-defaultvehicle";
|
||
private const string TruckFinishedKey = "client4229938-truck-finished";
|
||
|
||
/// <summary>
|
||
/// UniqueId is authored here, so its high-water mark MUST survive a restart — a reused id makes
|
||
/// two different orders look like one row (hard rule 104). UniqueId is a u32 on the wire, so the
|
||
/// counter is wrapped into [floor, floor + span) instead of being handed over raw.
|
||
/// </summary>
|
||
private const string TruckOrderCounter = "TruckOrder";
|
||
private const long TruckOrderFloor = 1_000_000_000L;
|
||
private const long TruckOrderSpan = 1_000_000_000L;
|
||
|
||
private sealed class TruckOrderBoard
|
||
{
|
||
public readonly List<GameMethods.TruckJobOrderWrap> Orders = new();
|
||
}
|
||
|
||
private static TruckOrderBoard Board(TcpSession session)
|
||
{
|
||
lock (session.Items)
|
||
{
|
||
if (session.Items.TryGetValue(TruckBoardKey, out var existing) && existing is TruckOrderBoard board)
|
||
return board;
|
||
var created = new TruckOrderBoard();
|
||
session.Items[TruckBoardKey] = created;
|
||
return created;
|
||
}
|
||
}
|
||
|
||
private static List<GameMethods.TruckJobOrderWrap> Finished(TcpSession session)
|
||
{
|
||
lock (session.Items)
|
||
{
|
||
if (session.Items.TryGetValue(TruckFinishedKey, out var existing)
|
||
&& existing is List<GameMethods.TruckJobOrderWrap> list)
|
||
return list;
|
||
var created = new List<GameMethods.TruckJobOrderWrap>();
|
||
session.Items[TruckFinishedKey] = created;
|
||
return created;
|
||
}
|
||
}
|
||
|
||
private static uint NextTruckOrderId()
|
||
{
|
||
var raw = IdCounterStore.Next(TruckOrderCounter, TruckOrderFloor);
|
||
return (uint)(TruckOrderFloor + (raw - TruckOrderFloor) % TruckOrderSpan);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------------------
|
||
// Board
|
||
// -----------------------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// AskGetTruckJobOrders(63386469) — no arguments, returns the whole take-order board.
|
||
///
|
||
/// Called when the panel opens and again on every new in-game day
|
||
/// (<c>ON_DELIVERY_TRUNK_ORDER_NEW_DAY</c> -> <c>DeliveryHomepagePanelStore.OnTruckOrderNewDay</c>).
|
||
/// </summary>
|
||
[Handler(MethodId.AskGetTruckJobOrders, HandlerPacketKind.Invoke)]
|
||
private Task AskGetTruckJobOrders(Connection conn, UxRpcMessage msg)
|
||
=> ReturnBoardAsync(conn, msg, "get");
|
||
|
||
/// <summary>
|
||
/// AskRefreshTruckOrder(63071191) — no arguments, same body. Rubber-banded by the client with
|
||
/// <c>UberSimConfig.TakeOrderRefreshTime</c> (15 s), so re-rolling (rather than appending) is the
|
||
/// behaviour that matches the cooldown the panel shows.
|
||
/// </summary>
|
||
[Handler(MethodId.AskRefreshTruckOrder, HandlerPacketKind.Invoke)]
|
||
private Task AskRefreshTruckOrder(Connection conn, UxRpcMessage msg)
|
||
=> ReturnBoardAsync(conn, msg, "refresh");
|
||
|
||
private async Task ReturnBoardAsync(Connection conn, UxRpcMessage msg, string via)
|
||
{
|
||
var session = msg.Context.Session;
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
var error = UberSimCatalog4229938.LoadError;
|
||
if (error is not null)
|
||
conn.Log.Info($"[TRUCK] board via={via} -> catalog unavailable: {error}");
|
||
|
||
var board = Board(session);
|
||
|
||
// Read the anchor before taking the board lock — it touches session.Items too.
|
||
var (bx, by, bz) = SpotOrigin(msg.Context, cfg);
|
||
|
||
// Off still answers — every one of these methods is an INVOKE, so dropping it would hang the
|
||
// callback instead of clearing the panel.
|
||
lock (board.Orders)
|
||
{
|
||
if (cfg.DeliveryEnabled)
|
||
{
|
||
// Keep rows already taken; re-roll the rest so refresh actually changes the offer.
|
||
for (var i = board.Orders.Count - 1; i >= 0; i--)
|
||
if (board.Orders[i].AcceptInfo is null)
|
||
board.Orders.RemoveAt(i);
|
||
|
||
var target = Math.Max(0, cfg.DeliveryBoardSize);
|
||
for (var i = board.Orders.Count; i < target; i++)
|
||
board.Orders.Add(BuildRow(i, bx, by, bz, cfg.DeliveryDropOffset));
|
||
}
|
||
else
|
||
{
|
||
board.Orders.Clear();
|
||
}
|
||
}
|
||
|
||
// Snapshot outside every lock: the reply is a network await and must not hold the board.
|
||
var view = NewView(session);
|
||
|
||
if (cfg.DeliveryReplyFraming == StructFraming)
|
||
await conn.ReturnAsync(msg, ToInline(view), Ok);
|
||
else
|
||
await conn.ReturnAsync(msg, view, Ok);
|
||
|
||
conn.Log.Info($"[TRUCK] board via={via} rows={view.Orders.Count} " +
|
||
$"taken={view.Orders.Count(x => x.AcceptInfo is not null)} " +
|
||
$"current={view.CurrentOrderId} auto={view.AutoAccept} " +
|
||
$"framing={cfg.DeliveryReplyFraming} head={HeadHex(view)}");
|
||
}
|
||
|
||
/// <summary>Struct el values and complex sisters must stay identical — see the pair's comment.</summary>
|
||
private const string StructFraming = "struct";
|
||
|
||
/// <summary>
|
||
/// 回给客户端的"成功" errorId。
|
||
///
|
||
/// ⛑ 实测结论(2026-09-25 04:35):客户端日志直接打出 <c>MessageConfig.Ok=0</c>,而配送这条线
|
||
/// 每个回调都写成 <c>if errorId == MessageConfig.Ok then 报错 + 回滚; return end</c>
|
||
/// (DeliveryHomepagePanelStore:810、DeliveryTaskManager:706 …)。
|
||
/// 于是服务端回 0 会被判成失败 —— 接单那条尤其明显:命中后会走
|
||
/// <c>RefreshOrderByAskTakeOrGiveUpFail()</c> 把刚接的单回滚掉,表现就是"点了没反应"。
|
||
/// ⇒ 成功必须回非 0。值本身不重要(错误分支才会 DisplayServerMessageId),1 即可。
|
||
/// 可配置是为了万一别的分支期望 0,改 json 就能回退。
|
||
/// </summary>
|
||
private static int Ok => PrivateServerConfigStore.Current.Gameplay.DeliverySuccessErrorId;
|
||
|
||
private static GameMethods.ClientTruckOrderView NewView(TcpSession session)
|
||
{
|
||
var board = Board(session);
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
return new GameMethods.ClientTruckOrderView
|
||
{
|
||
Orders = new List<GameMethods.TruckJobOrderWrap>(board.Orders),
|
||
RewardPointSum = (int)cfg.DeliveryRewardPointSum,
|
||
CustomerSatisfactionAverage = cfg.DeliverySatisfaction,
|
||
CurrentOrderId = board.Orders.FirstOrDefault(x => x.AcceptInfo is not null)?.UniqueId ?? 0u,
|
||
TruckGuideClicked = false,
|
||
EventIdToAgent = new Dictionary<uint, ulong>(),
|
||
AutoAccept = TruckFlag(session, TruckAutoAcceptKey),
|
||
DefaultVehicleId = TruckVehicle(session),
|
||
TotalIncome = (int)cfg.DeliveryTotalIncome,
|
||
};
|
||
}
|
||
|
||
private static GameMethods.ClientTruckOrderViewInline ToInline(GameMethods.ClientTruckOrderView view)
|
||
=> new()
|
||
{
|
||
Orders = view.Orders,
|
||
RewardPointSum = view.RewardPointSum,
|
||
CustomerSatisfactionAverage = view.CustomerSatisfactionAverage,
|
||
CurrentOrderId = view.CurrentOrderId,
|
||
TruckGuideClicked = view.TruckGuideClicked,
|
||
EventIdToAgent = view.EventIdToAgent,
|
||
AutoAccept = view.AutoAccept,
|
||
DefaultVehicleId = view.DefaultVehicleId,
|
||
TotalIncome = view.TotalIncome,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Where a generated order's pickup sits. All zeros when <c>deliverySpotsAtPlayer</c> is off —
|
||
/// the pre-2026-09-25 behaviour, kept as the rollback for the coordinate work.
|
||
/// </summary>
|
||
private (float X, float Y, float Z) SpotOrigin(RpcContext ctx, GameplaySettings cfg)
|
||
{
|
||
if (!cfg.DeliverySpotsAtPlayer)
|
||
return (0f, 0f, 0f);
|
||
|
||
var state = GetWorldState(ctx);
|
||
return (state.LastReportedPlayerPosition.X, state.LastReportedPlayerPosition.Y, state.LastReportedPlayerPosition.Z);
|
||
}
|
||
|
||
/// <summary>
|
||
/// One row of the board. everything authorable comes from the UberSim tables so the client can
|
||
/// resolve its own labels; only ids the server owns (UniqueId, times) are invented here.
|
||
/// </summary>
|
||
private static GameMethods.TruckJobOrderWrap BuildRow(int index, float ox, float oy, float oz, float offset)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
|
||
var orderType = Pick(UberSimCatalog4229938.OrderTypes);
|
||
var cargo = Pick(UberSimCatalog4229938.Cargos);
|
||
var estimated = Math.Max(1, cfg.DeliveryEstimatedFinishSeconds);
|
||
|
||
var wrap = new GameMethods.TruckJobOrderWrap
|
||
{
|
||
UniqueId = NextTruckOrderId(),
|
||
OrderInfoStartTime = now,
|
||
AcceptInfo = null,
|
||
ResultInfo = null,
|
||
CargoPickedUp = false,
|
||
CargoIntegrity = 100f,
|
||
};
|
||
|
||
var info = wrap.OrderInfo;
|
||
info.CargoId = cargo?.Id ?? 0u;
|
||
info.OrderType = orderType?.Id ?? 0u;
|
||
info.IsEmergency = false;
|
||
info.LimitAcceptSeconds = Math.Max(1, UberSimCatalog4229938.InitAcceptTime);
|
||
info.EstimatedFinishSeconds = estimated;
|
||
info.LimitFinishSeconds = estimated * 2;
|
||
info.BasePointReward = (int)cfg.DeliveryBasePointReward;
|
||
info.DropCoefficient = 1f;
|
||
info.DropMoney = 500 + (cargo?.DisasterLevel ?? 0) * 300 + Random.Shared.Next(0, 201);
|
||
info.AddDropCoefficient = 1f;
|
||
info.SpecialOrderId = 0u;
|
||
info.SpecialPointReward = 0;
|
||
info.ActivityIndex = 0u;
|
||
info.RandomOrderId = 0u;
|
||
info.IsDailyOrder = false;
|
||
|
||
// One highlighted row every four gives the "高价单排最前" ordering something to reorder.
|
||
info.IsHighValue = index % 4 == 0;
|
||
|
||
var pickup = Pick(UberSimCatalog4229938.PickupIds);
|
||
var delivery = Pick(UberSimCatalog4229938.DeliveryIds);
|
||
var raid = UberSimCatalog4229938.DefaultRaidId;
|
||
info.StartPos.ConfigId = pickup;
|
||
info.StartPos.RaidId = raid;
|
||
info.StartPos.Pos = new GameMethods.UxVector3 { X = ox, Y = oy, Z = oz };
|
||
info.EndPos.ConfigId = delivery;
|
||
info.EndPos.RaidId = raid;
|
||
info.EndPos.Pos = new GameMethods.UxVector3 { X = ox + offset, Y = oy, Z = oz + offset };
|
||
|
||
// A single parcel per row — the multi-parcel variants are all scripted orders.
|
||
info.CargoInfoList.Add(new GameMethods.TruckCargoInfo
|
||
{
|
||
CargoId = info.CargoId,
|
||
Integrity = 100,
|
||
IsCargoNear = false,
|
||
UniqueId = info.CargoId,
|
||
});
|
||
info.CargoInfoList[0].StartPos.ConfigId = pickup;
|
||
info.CargoInfoList[0].StartPos.RaidId = raid;
|
||
info.CargoInfoList[0].StartPos.Pos = new GameMethods.UxVector3 { X = ox, Y = oy, Z = oz };
|
||
|
||
return wrap;
|
||
}
|
||
|
||
private static T? Pick<T>(IReadOnlyList<T> source) where T : class
|
||
=> source.Count == 0 ? null : source[Random.Shared.Next(source.Count)];
|
||
|
||
private static uint Pick(IReadOnlyList<uint> source)
|
||
=> source.Count == 0 ? 0u : source[Random.Shared.Next(source.Count)];
|
||
|
||
private static uint TruckVehicle(TcpSession session)
|
||
{
|
||
var configured = PrivateServerConfigStore.Current.Gameplay.DeliveryDefaultVehicleId;
|
||
if (configured != 0)
|
||
return configured;
|
||
lock (session.Items)
|
||
return session.Items.TryGetValue(TruckDefaultVehicleKey, out var v) && v is uint stored ? stored : 0u;
|
||
}
|
||
|
||
private static bool TruckFlag(TcpSession session, string key)
|
||
{
|
||
lock (session.Items)
|
||
return session.Items.TryGetValue(key, out var v) && v is bool b && b;
|
||
}
|
||
|
||
private static void SetTruckFlag(TcpSession session, string key, bool value)
|
||
{
|
||
lock (session.Items)
|
||
session.Items[key] = value;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------------------
|
||
// Take / drop
|
||
// -----------------------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// AskAcceptTruckJobOrder(63110537) — (uint id) -> (TruckJobOrderWrap, ulong npcInstanceId).
|
||
///
|
||
/// The cap itself is client-side (<c>DeliveryHomepagePanelStore.CheckCanTakeOrder</c>:
|
||
/// <c>UberSimConfig.InitAcceptCount</c> plus one per badge in AddAcceptBadgeList), and it counts
|
||
/// rows whose AcceptInfo is set — so stamping AcceptInfo is what makes the cap move, which is
|
||
/// also why the server must not silently accept past it (the client would then disagree with its
|
||
/// own button state).
|
||
/// </summary>
|
||
[Handler(MethodId.AskAcceptTruckJobOrder, HandlerPacketKind.Invoke)]
|
||
private async Task AskAcceptTruckJobOrder(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var id = BodyU32(msg);
|
||
var board = Board(msg.Context.Session);
|
||
var cap = Math.Max(1, UberSimCatalog4229938.InitAcceptCount);
|
||
|
||
GameMethods.TruckJobOrderWrap? row = null;
|
||
lock (board.Orders)
|
||
{
|
||
row = board.Orders.FirstOrDefault(x => x.UniqueId == id && x.AcceptInfo is null);
|
||
var taken = board.Orders.Count(x => x.AcceptInfo is not null);
|
||
if (row is not null && taken < cap)
|
||
row.AcceptInfo = new GameMethods.TruckJobOrderAccept
|
||
{
|
||
AcceptedEventId = row.OrderInfo.OrderType == 0 ? 0u : row.OrderInfo.OrderType,
|
||
AcceptTime = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||
};
|
||
else
|
||
row = null;
|
||
}
|
||
|
||
if (row is null)
|
||
{
|
||
conn.Log.Info($"[TRUCK] accept id={id} -> refused (unknown row or cap reached)");
|
||
await conn.ReturnAsync(msg, new GameMethods.AcceptTruckJobOrderReply(), Ok);
|
||
return;
|
||
}
|
||
|
||
conn.Log.Info($"[TRUCK] accept id={id} orderType={row.OrderInfo.OrderType} cargo={row.OrderInfo.CargoId} money={row.OrderInfo.DropMoney}");
|
||
await conn.ReturnAsync(msg, new GameMethods.AcceptTruckJobOrderReply { OrderWrap = row, NpcInstanceId = 0UL }, Ok);
|
||
|
||
// ⛑ The reply alone does NOT move the panel: DeliveryHomepagePanelStore.lua:809 only looks
|
||
// at errorId, and the store's canInteract latch is released exclusively by
|
||
// OnAcceptTruckJobOrder (:819), which listens for ON_ACCEPT_TRUCK_JOB_ORDER — raised by
|
||
// GameToClientImpl.SyncAcceptTruckJobOrder (:1761). Shipping the reply without this push is
|
||
// exactly the "点了接单之后没有反应" the player reported.
|
||
if (PrivateServerConfigStore.Current.Gameplay.DeliveryAcceptSyncEnabled)
|
||
{
|
||
await msg.Context.NotifyAsync(MethodId.SyncAcceptTruckJobOrder,
|
||
new GameMethods.SyncAcceptTruckJobOrderNotify
|
||
{
|
||
Id = row.UniqueId,
|
||
OrderInfo = row,
|
||
DeliveryAgentId = 0UL,
|
||
DeliveryGadgetId = 0UL,
|
||
});
|
||
conn.Log.Info($"[TRUCK] accept-sync pushed id={row.UniqueId}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskObsoleteTruckJobOrder(63756681) — (uint id), void. Drops the row off the board for the
|
||
/// rest of the in-game day, which is what the panel does locally before awaiting the reply.
|
||
/// </summary>
|
||
[Handler(MethodId.AskObsoleteTruckJobOrder, HandlerPacketKind.Invoke)]
|
||
private Task AskObsoleteTruckJobOrder(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var id = BodyU32(msg);
|
||
var board = Board(msg.Context.Session);
|
||
lock (board.Orders)
|
||
board.Orders.RemoveAll(x => x.UniqueId == id);
|
||
|
||
conn.Log.Info($"[TRUCK] obsolete id={id} left={board.Orders.Count}");
|
||
return conn.ReturnRawAsync(msg, [], Ok);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskAutoAcceptTruckJobOrder(63913941) — (bool bAutoAccept), void. Persisted per session and
|
||
/// echoed back in <c>ClientTruckOrderView.AutoAccept</c> so the toggle keeps its state.
|
||
/// </summary>
|
||
[Handler(MethodId.AskAutoAcceptTruckJobOrder, HandlerPacketKind.Invoke)]
|
||
private Task AskAutoAcceptTruckJobOrder(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var enable = msg.Body.Length < 1 || msg.Body[0] != 0;
|
||
SetTruckFlag(msg.Context.Session, TruckAutoAcceptKey, enable);
|
||
conn.Log.Info($"[TRUCK] autoAccept={enable}");
|
||
return conn.ReturnRawAsync(msg, [], Ok);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskSetTruckJobDefaultVehicleId(63784367) — (uint vehicleId), void.
|
||
///
|
||
/// The argument comes from the vehicle list panel opened by 叫车's detail button
|
||
/// (<c>DeliveryTaskManager.CallOpenCallCar</c>); it only has to be remembered and echoed, because
|
||
/// the actual summon goes out as AskSummonVehicle from <c>OnOrderSupportItemClick</c>.
|
||
/// </summary>
|
||
[Handler(MethodId.AskSetTruckJobDefaultVehicleId, HandlerPacketKind.Invoke)]
|
||
private Task AskSetTruckJobDefaultVehicleId(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var vehicleId = BodyU32(msg);
|
||
lock (msg.Context.Session.Items)
|
||
msg.Context.Session.Items[TruckDefaultVehicleKey] = vehicleId;
|
||
|
||
conn.Log.Info($"[TRUCK] defaultVehicle={vehicleId}");
|
||
return conn.ReturnRawAsync(msg, [], Ok);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------------------
|
||
// Pickup / delivery points
|
||
// -----------------------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// AskQueryTruckPosInfo(63763434) — (list7bit u32 pickupIds, list7bit u32 deliveryIds)
|
||
/// -> (list7bit TruckPosInfo, list7bit TruckPosInfo).
|
||
///
|
||
/// ⛑ Only caller: <c>DeliveryTaskManager.SetSpoonCustomOrderData</c> (:1870), the custom/spoon
|
||
/// order path — not the normal take-order row. It caches per id and reads <c>[1]</c> of each
|
||
/// list only, so one entry per queried id, in request order, is all the client consumes.
|
||
///
|
||
/// The server has no authored world points (UberSimPickupConfig / UberSimDeliveryConfig carry
|
||
/// names and tags only — no coordinates), so the spot is placed relative to the player's last
|
||
/// reported position: pickup on the player, drop-off <c>deliveryDropOffset</c> away. That keeps
|
||
/// the map pin somewhere reachable instead of at the world origin; flip
|
||
/// <c>gameplay.deliverySpotsAtPlayer</c> to false to get the old all-zero behaviour back.
|
||
/// </summary>
|
||
[Handler(MethodId.AskQueryTruckPosInfo, HandlerPacketKind.Invoke)]
|
||
private async Task AskQueryTruckPosInfo(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var cursor = 0;
|
||
var pickupIds = ReadU32List7BitAt(msg.Body, ref cursor);
|
||
var deliveryIds = ReadU32List7BitAt(msg.Body, ref cursor);
|
||
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
var reply = new GameMethods.TruckPosInfoQueryReply();
|
||
|
||
float px = 0f, py = 0f, pz = 0f;
|
||
if (cfg.DeliverySpotsAtPlayer)
|
||
{
|
||
var state = GetWorldState(msg.Context);
|
||
px = state.LastReportedPlayerPosition.X;
|
||
py = state.LastReportedPlayerPosition.Y;
|
||
pz = state.LastReportedPlayerPosition.Z;
|
||
}
|
||
|
||
var raid = UberSimCatalog4229938.DefaultRaidId;
|
||
var offset = cfg.DeliveryDropOffset;
|
||
|
||
foreach (var id in pickupIds)
|
||
reply.PickUpTruckPosInfos.Add(Spot(id, raid, px, py, pz));
|
||
|
||
// Offset on both X and Z so the drop-off is never hidden under the pickup marker.
|
||
foreach (var id in deliveryIds)
|
||
reply.DeliveryTruckPosInfos.Add(Spot(id, raid, px + offset, py, pz + offset));
|
||
|
||
conn.Log.Info($"[TRUCK] queryPos pickups=[{string.Join(",", pickupIds)}] " +
|
||
$"deliveries=[{string.Join(",", deliveryIds)}] atPlayer={cfg.DeliverySpotsAtPlayer} " +
|
||
$"origin=({px:0.#},{py:0.#},{pz:0.#}) offset={offset:0.#} raid={raid} " +
|
||
$"head={HeadHex(reply)}");
|
||
|
||
await conn.ReturnAsync(msg, reply, Ok);
|
||
}
|
||
|
||
/// <summary>
|
||
/// One <c>TruckPosInfo</c>. WpId stays 0 — it is a world-point id from a table the server does
|
||
/// not have, and the client only feeds these straight into the order it is assembling.
|
||
/// </summary>
|
||
private static GameMethods.TruckPosInfo Spot(uint configId, uint raid, float x, float y, float z)
|
||
=> new()
|
||
{
|
||
WpId = 0,
|
||
ConfigId = configId,
|
||
Pos = new GameMethods.UxVector3 { X = x, Y = y, Z = z },
|
||
Rot = new GameMethods.UxVector3(),
|
||
GadgetUId = 0UL,
|
||
DeliveryGadgetInfoList = new List<GameMethods.TruckDeliveryGadgetInfo>(),
|
||
RaidId = raid,
|
||
};
|
||
|
||
/// <summary>
|
||
/// list7bit<u32> 解包(带游标,供一条请求里连读两个列表)。
|
||
///
|
||
/// ⛑ 首字节是集合标记:0xFF = 非空(<c>SerializeBase.WriteList7Bit</c> 写
|
||
/// <c>SerializeObjectMarkCommon</c>),0x00 = null。一个正常的项数 varint 不会以 0xFF 开头
|
||
/// (那意味着 count ≥ 2^28),所以"见到 0xFF 就跳过"是无歧义的。
|
||
/// 项数带 +1 偏置(<c>UxWriter.Int7</c>),与 <c>GameRouter.Extraction.cs</c> 的同名工具一致。
|
||
/// </summary>
|
||
private static List<uint> ReadU32List7BitAt(ReadOnlySpan<byte> body, ref int pos)
|
||
{
|
||
var result = new List<uint>();
|
||
if (pos >= body.Length)
|
||
return result;
|
||
|
||
if (body[pos] == 0xFF)
|
||
pos++;
|
||
|
||
var count = ReadVarInt(body, ref pos) - 1;
|
||
if (count <= 0)
|
||
return result;
|
||
|
||
for (var i = 0; i < count && pos + 4 <= body.Length; i++)
|
||
{
|
||
result.Add(BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos, 4)));
|
||
pos += 4;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------------------
|
||
// Log / summary / tutorial
|
||
// -----------------------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// AskGetFinishedOrderWraps(63545189) — no arguments, returns the completed-log body
|
||
/// (<c>Auto.WriteClientFinishedTruckOrderView</c>, RPCSerializeAuto.lua:2273).
|
||
///
|
||
/// ⛑⛑ 每一行都必须带齐 <c>AcceptInfo</c> 和 <c>ResultInfo</c>,否则面板直接炸而不是显示"暂无":
|
||
/// · <c>DeliveryLogPhonePanelStore.GetTotalMoney</c> 读 <c>ResultInfo.DropMoney</c>
|
||
/// · <c>RefreshOrderListView</c> 读 <c>ResultInfo.FinishTime</c> 做排序
|
||
/// · <c>OnRenderItem</c> 读 <c>ResultInfo.CargoIntegrity / Evaluation</c> 与
|
||
/// <c>AcceptInfo.AcceptTime</c> 算耗时
|
||
/// · 详情 <c>RefreshOrderDetailView</c>(:1264) 第一句就是
|
||
/// <c>if resultInfo ~= nil then return end</c>(判定又是反的,详见规则 135)
|
||
///
|
||
/// 列表内容来自两处:真实结算(<c>AskSettleTruckOrder</c> 挪进来的行)和
|
||
/// <c>gameplay.deliveryLogSeedCount</c> 的<b>历史补账</b>。之所以要补账:客户端那条结算链
|
||
/// (<c>PreSettleTruckOrder</c>:571 / <c>FinishDeliveryOrder</c>:705) 只被任务系统驱动,
|
||
/// 面板上没有任何入口,所以在这条链接通之前,日志页只能由服务端预置内容。
|
||
/// 回滚:把 <c>deliveryLogSeedCount</c> 置 0 就只剩真实结算的行。
|
||
/// </summary>
|
||
[Handler(MethodId.AskGetFinishedOrderWraps, HandlerPacketKind.Invoke)]
|
||
private async Task AskGetFinishedOrderWraps(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
var session = msg.Context.Session;
|
||
var finished = Finished(session);
|
||
|
||
lock (finished)
|
||
{
|
||
var target = Math.Max(0, cfg.DeliveryLogSeedCount);
|
||
if (finished.Count < target)
|
||
{
|
||
var (ox, oy, oz) = SpotOrigin(msg.Context, cfg);
|
||
for (var i = finished.Count; i < target; i++)
|
||
finished.Add(FinishedRow(i, ox, oy, oz, cfg.DeliveryDropOffset, cfg, i));
|
||
}
|
||
}
|
||
|
||
var rows = new List<GameMethods.TruckJobOrderWrap>(finished);
|
||
var income = rows.Sum(x => x.ResultInfo?.DropMoney ?? 0);
|
||
var points = rows.Sum(x => x.ResultInfo?.RewardPoint ?? 0);
|
||
|
||
if (cfg.DeliveryReplyFraming == StructFraming)
|
||
{
|
||
await conn.ReturnAsync(msg, new GameMethods.ClientFinishedTruckOrderViewInline
|
||
{
|
||
FinishedOrders = rows,
|
||
TodayTotalIncome = income,
|
||
TodayRewardPoint = points,
|
||
TotalActivityPointRewards = points,
|
||
}, Ok);
|
||
}
|
||
else
|
||
{
|
||
await conn.ReturnAsync(msg, new GameMethods.ClientFinishedTruckOrderView
|
||
{
|
||
FinishedOrders = rows,
|
||
TodayTotalIncome = income,
|
||
TodayRewardPoint = points,
|
||
TotalActivityPointRewards = points,
|
||
}, Ok);
|
||
}
|
||
|
||
conn.Log.Info($"[TRUCK] finishedWraps rows={rows.Count} income={income} points={points} " +
|
||
$"head={HeadHex(rows.Count > 0 ? rows[0] : new GameMethods.TruckJobOrderWrap())}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 一条「已完成」的行:照搬看板行的生成方式,再补上收货时间 / 结算结果。
|
||
///
|
||
/// <paramref name="slot"/> 只是为了让几条补账行的时间各不相同(第 n 条是 n 小时前完成),
|
||
/// 这样列表按 <c>FinishTime</c> 排序和「今日收入」那一栏才看得出前后关系。
|
||
/// </summary>
|
||
private static GameMethods.TruckJobOrderWrap FinishedRow(
|
||
int slot, float ox, float oy, float oz, float offset, GameplaySettings cfg, int seedIndex)
|
||
{
|
||
var row = BuildRow(slot, ox, oy, oz, offset);
|
||
var elapsed = Math.Max(60, row.OrderInfo.EstimatedFinishSeconds);
|
||
var finish = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds() - (uint)(seedIndex * 3600);
|
||
var accept = finish - (uint)elapsed;
|
||
|
||
row.OrderInfoStartTime = accept - 30;
|
||
row.CargoPickedUp = true;
|
||
row.AcceptInfo = new GameMethods.TruckJobOrderAccept
|
||
{
|
||
AcceptedEventId = row.OrderInfo.OrderType,
|
||
AcceptTime = accept,
|
||
};
|
||
row.ResultInfo = new GameMethods.TruckJobOrderResult
|
||
{
|
||
FinishTime = finish,
|
||
CargoIntegrity = 100,
|
||
DropId = 0u,
|
||
DropCoefficient = row.OrderInfo.DropCoefficient <= 0f ? 1f : row.OrderInfo.DropCoefficient,
|
||
RewardPoint = row.OrderInfo.BasePointReward + row.OrderInfo.SpecialPointReward,
|
||
Evaluation = 5,
|
||
CustomerSatisfaction = cfg.DeliverySatisfaction,
|
||
DropMoney = row.OrderInfo.DropMoney,
|
||
Dropped = true,
|
||
IsCargoNear = false,
|
||
AddBuffList = new List<uint>(),
|
||
RemoveBuffList = new List<uint>(),
|
||
OrderDeliverUpSetId = 0u,
|
||
DeliverUpset = 0u,
|
||
};
|
||
return row;
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskGetTruckSatisfactionAverage(63344040) — no arguments, returns a float.
|
||
///
|
||
/// This one already reaches the server today: it shows up 16 times in unknown-methods.log while
|
||
/// the app sat open, and a 0-byte INVOKE is exactly the shape that hangs the callback until it
|
||
/// times out (hard rule 94). Serving it removes that stall even before anything else lands.
|
||
/// </summary>
|
||
[Handler(MethodId.AskGetTruckSatisfactionAverage, HandlerPacketKind.Invoke)]
|
||
private Task AskGetTruckSatisfactionAverage(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var value = PrivateServerConfigStore.Current.Gameplay.DeliverySatisfaction;
|
||
conn.Log.Info($"[TRUCK] satisfactionAverage={value:0.##}");
|
||
return conn.ReturnAsync(msg, value, Ok);
|
||
}
|
||
|
||
/// <summary>AskStartTruckOrderGuide(63637032) — no arguments, void. Tutorial latch.</summary>
|
||
[Handler(MethodId.AskStartTruckOrderGuide, HandlerPacketKind.Invoke)]
|
||
private Task AskStartTruckOrderGuide(Connection conn, UxRpcMessage msg)
|
||
{
|
||
conn.Log.Info("[TRUCK] startGuide");
|
||
return conn.ReturnRawAsync(msg, [], Ok);
|
||
}
|
||
|
||
private static uint BodyU32(UxRpcMessage msg)
|
||
=> msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
|
||
|
||
// -----------------------------------------------------------------------------------------
|
||
// Settle chain — 完成结算 / 满意度评分 / 送货日志
|
||
//
|
||
// 这段只为把面板的文字内容顶出来,不做玩法:没有真实的货物完好度计算、没有里程、
|
||
// 没有路线校验。行在 PreSettle 时被打上"已完成",Settle 时从看板移进已完成列表,
|
||
// 之后 AskGetFinishedOrderWraps 就能把它读回去。
|
||
// -----------------------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// AskPreSettleTruckOrder(63818375) — (uint uniqueId, list TruckCargoSettleInfo) -> (TruckJobOrderWrap).
|
||
///
|
||
/// 客户端 <c>PreSettleTruckOrder:571</c> 的回调是 <c>(err, truckJobOrderWrap)</c>,命中成功分支
|
||
/// 后把 <c>info.result = truckJobOrderWrap</c> 并 <c>SetOrderComplete(info, true)</c>,
|
||
/// 结算面板就是靠这个 wrap 渲染的。货物完好度清单我们收下但不读 —— 那需要真实路线的里程/颠簸。
|
||
/// </summary>
|
||
[Handler(MethodId.AskPreSettleTruckOrder, HandlerPacketKind.Invoke)]
|
||
private async Task AskPreSettleTruckOrder(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var id = BodyU32(msg);
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
var board = Board(msg.Context.Session);
|
||
|
||
GameMethods.TruckJobOrderWrap? row;
|
||
lock (board.Orders)
|
||
{
|
||
row = board.Orders.FirstOrDefault(x => x.UniqueId == id);
|
||
if (row is not null)
|
||
row.AcceptInfo ??= new GameMethods.TruckJobOrderAccept
|
||
{
|
||
AcceptedEventId = row.OrderInfo.OrderType,
|
||
AcceptTime = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||
};
|
||
}
|
||
|
||
if (row is null)
|
||
{
|
||
conn.Log.Info($"[TRUCK] preSettle id={id} -> no such row");
|
||
await conn.ReturnRawAsync(msg, [], cfg.DeliverySuccessErrorId);
|
||
return;
|
||
}
|
||
|
||
conn.Log.Info($"[TRUCK] preSettle id={id} orderType={row.OrderInfo.OrderType} " +
|
||
$"money={row.OrderInfo.DropMoney} err={cfg.DeliverySuccessErrorId}");
|
||
await conn.ReturnAsync(msg, row, cfg.DeliverySuccessErrorId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskSettleTruckOrder(63873513) — (uint uniqueId), void.
|
||
///
|
||
/// 客户端 <c>FinishDeliveryOrder:705</c> 成功后关倒计时面板并推送 ON_GOOD_OPEN_INTEGRITY_PLANE,
|
||
/// 也就是评分那段演出。这里把行从看板挪进已完成列表,好让「送货日志」读得到。
|
||
///
|
||
/// ⛑⛑ 挪之前必须补 <c>AcceptInfo</c> + <c>ResultInfo</c> —— 日志面板在它们上面取字段,
|
||
/// 留 null 会直接报错而不是显示空。详见 <c>AskGetFinishedOrderWraps</c> 的注释。
|
||
/// </summary>
|
||
[Handler(MethodId.AskSettleTruckOrder, HandlerPacketKind.Invoke)]
|
||
private Task AskSettleTruckOrder(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var id = BodyU32(msg);
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
var board = Board(msg.Context.Session);
|
||
var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
|
||
var finished = Finished(msg.Context.Session);
|
||
GameMethods.TruckJobOrderWrap? row = null;
|
||
lock (board.Orders)
|
||
{
|
||
row = board.Orders.FirstOrDefault(x => x.UniqueId == id);
|
||
if (row is not null)
|
||
{
|
||
row.AcceptInfo ??= new GameMethods.TruckJobOrderAccept
|
||
{
|
||
AcceptedEventId = row.OrderInfo.OrderType,
|
||
AcceptTime = now - (uint)Math.Max(60, row.OrderInfo.EstimatedFinishSeconds),
|
||
};
|
||
row.ResultInfo ??= new GameMethods.TruckJobOrderResult
|
||
{
|
||
FinishTime = now,
|
||
CargoIntegrity = 100,
|
||
DropId = 0u,
|
||
DropCoefficient = row.OrderInfo.DropCoefficient <= 0f ? 1f : row.OrderInfo.DropCoefficient,
|
||
RewardPoint = row.OrderInfo.BasePointReward + row.OrderInfo.SpecialPointReward,
|
||
Evaluation = 5,
|
||
CustomerSatisfaction = cfg.DeliverySatisfaction,
|
||
DropMoney = row.OrderInfo.DropMoney,
|
||
Dropped = true,
|
||
IsCargoNear = false,
|
||
AddBuffList = new List<uint>(),
|
||
RemoveBuffList = new List<uint>(),
|
||
OrderDeliverUpSetId = 0u,
|
||
DeliverUpset = 0u,
|
||
};
|
||
board.Orders.Remove(row);
|
||
}
|
||
}
|
||
|
||
var total = 0;
|
||
if (row is not null)
|
||
lock (finished)
|
||
{
|
||
finished.Add(row);
|
||
if (finished.Count > 64)
|
||
finished.RemoveRange(0, finished.Count - 64);
|
||
total = finished.Count;
|
||
}
|
||
|
||
conn.Log.Info($"[TRUCK] settle id={id} moved={(row is not null).ToString().ToLowerInvariant()} " +
|
||
$"finished={total} err={cfg.DeliverySuccessErrorId}");
|
||
return conn.ReturnRawAsync(msg, [], cfg.DeliverySuccessErrorId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskFinishJob(63333823) — () -> void. 职业侧的"结束今日工作",与订单无关,回空即可。
|
||
/// </summary>
|
||
[Handler(MethodId.AskFinishJob, HandlerPacketKind.Invoke)]
|
||
private Task AskFinishJob(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
conn.Log.Info($"[TRUCK] finishJob err={cfg.DeliverySuccessErrorId}");
|
||
return conn.ReturnRawAsync(msg, [], cfg.DeliverySuccessErrorId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskAddTruckOrderSpecialPointRewards(63076643) — (list uint orderIds, int pointId), void.
|
||
/// 特殊点位的额外奖励领取,回空即可(奖励入账没接货币系统)。
|
||
/// </summary>
|
||
[Handler(MethodId.AskAddTruckOrderSpecialPointRewards, HandlerPacketKind.Invoke)]
|
||
private Task AskAddTruckOrderSpecialPointRewards(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
conn.Log.Info($"[TRUCK] specialPointRewards err={cfg.DeliverySuccessErrorId}");
|
||
return conn.ReturnRawAsync(msg, [], cfg.DeliverySuccessErrorId);
|
||
}
|
||
|
||
/// <summary>First bytes of a reply, so a framing mismatch can be told apart without a client log.</summary>
|
||
/// ⛑ MUST stay generic: UxSerializer.Serialize dispatches on the STATIC type argument, so an
|
||
/// <c>object</c> parameter makes every overload here report "System.Object is not a UX contract"
|
||
/// even though the real reply serializes fine. That false alarm cost a debugging round already.
|
||
private static string HeadHex<T>(T value)
|
||
{
|
||
try
|
||
{
|
||
var bytes = UxSerializer.Serialize(value);
|
||
return bytes.Length == 0 ? "(empty)" : BitConverter.ToString(bytes, 0, Math.Min(16, bytes.Length));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return $"serialize-failed:{ex.Message}";
|
||
}
|
||
}
|
||
}
|