Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.Delivery.cs
T

820 lines
38 KiB
C#
Raw 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 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&lt;u32&gt; 解包(带游标,供一条请求里连读两个列表)。
///
/// ⛑ 首字节是集合标记: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}";
}
}
}