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;
///
/// 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 AskGetTruckJobOrders / AskRefreshTruckOrder
/// · taking and dropping a row AskAcceptTruckJobOrder / AskObsoleteTruckJobOrder
/// · the finished-log page AskGetFinishedOrderWraps
/// · the account-level satisfaction average AskGetTruckSatisfactionAverage
/// · the two support buttons rendered by RefreshCallFunctionListView
/// – 叫车 already works: the panel calls the client-side AskSummonVehicle(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
/// (AskSetTruckJobDefaultVehicleId). 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
/// gameplay.deliverySupportBuffIds 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.
///
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";
///
/// 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.
///
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 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 Finished(TcpSession session)
{
lock (session.Items)
{
if (session.Items.TryGetValue(TruckFinishedKey, out var existing)
&& existing is List list)
return list;
var created = new List();
session.Items[TruckFinishedKey] = created;
return created;
}
}
private static uint NextTruckOrderId()
{
var raw = IdCounterStore.Next(TruckOrderCounter, TruckOrderFloor);
return (uint)(TruckOrderFloor + (raw - TruckOrderFloor) % TruckOrderSpan);
}
// -----------------------------------------------------------------------------------------
// Board
// -----------------------------------------------------------------------------------------
///
/// AskGetTruckJobOrders(63386469) — no arguments, returns the whole take-order board.
///
/// Called when the panel opens and again on every new in-game day
/// (ON_DELIVERY_TRUNK_ORDER_NEW_DAY -> DeliveryHomepagePanelStore.OnTruckOrderNewDay).
///
[Handler(MethodId.AskGetTruckJobOrders, HandlerPacketKind.Invoke)]
private Task AskGetTruckJobOrders(Connection conn, UxRpcMessage msg)
=> ReturnBoardAsync(conn, msg, "get");
///
/// AskRefreshTruckOrder(63071191) — no arguments, same body. Rubber-banded by the client with
/// UberSimConfig.TakeOrderRefreshTime (15 s), so re-rolling (rather than appending) is the
/// behaviour that matches the cooldown the panel shows.
///
[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)}");
}
/// Struct el values and complex sisters must stay identical — see the pair's comment.
private const string StructFraming = "struct";
///
/// 回给客户端的"成功" errorId。
///
/// ⛑ 实测结论(2026-09-25 04:35):客户端日志直接打出 MessageConfig.Ok=0,而配送这条线
/// 每个回调都写成 if errorId == MessageConfig.Ok then 报错 + 回滚; return end
/// (DeliveryHomepagePanelStore:810、DeliveryTaskManager:706 …)。
/// 于是服务端回 0 会被判成失败 —— 接单那条尤其明显:命中后会走
/// RefreshOrderByAskTakeOrGiveUpFail() 把刚接的单回滚掉,表现就是"点了没反应"。
/// ⇒ 成功必须回非 0。值本身不重要(错误分支才会 DisplayServerMessageId),1 即可。
/// 可配置是为了万一别的分支期望 0,改 json 就能回退。
///
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(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(),
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,
};
///
/// Where a generated order's pickup sits. All zeros when deliverySpotsAtPlayer is off —
/// the pre-2026-09-25 behaviour, kept as the rollback for the coordinate work.
///
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);
}
///
/// 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.
///
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(IReadOnlyList source) where T : class
=> source.Count == 0 ? null : source[Random.Shared.Next(source.Count)];
private static uint Pick(IReadOnlyList 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
// -----------------------------------------------------------------------------------------
///
/// AskAcceptTruckJobOrder(63110537) — (uint id) -> (TruckJobOrderWrap, ulong npcInstanceId).
///
/// The cap itself is client-side (DeliveryHomepagePanelStore.CheckCanTakeOrder:
/// UberSimConfig.InitAcceptCount 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).
///
[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}");
}
}
///
/// 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.
///
[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);
}
///
/// AskAutoAcceptTruckJobOrder(63913941) — (bool bAutoAccept), void. Persisted per session and
/// echoed back in ClientTruckOrderView.AutoAccept so the toggle keeps its state.
///
[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);
}
///
/// AskSetTruckJobDefaultVehicleId(63784367) — (uint vehicleId), void.
///
/// The argument comes from the vehicle list panel opened by 叫车's detail button
/// (DeliveryTaskManager.CallOpenCallCar); it only has to be remembered and echoed, because
/// the actual summon goes out as AskSummonVehicle from OnOrderSupportItemClick.
///
[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
// -----------------------------------------------------------------------------------------
///
/// AskQueryTruckPosInfo(63763434) — (list7bit u32 pickupIds, list7bit u32 deliveryIds)
/// -> (list7bit TruckPosInfo, list7bit TruckPosInfo).
///
/// ⛑ Only caller: DeliveryTaskManager.SetSpoonCustomOrderData (:1870), the custom/spoon
/// order path — not the normal take-order row. It caches per id and reads [1] 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 deliveryDropOffset away. That keeps
/// the map pin somewhere reachable instead of at the world origin; flip
/// gameplay.deliverySpotsAtPlayer to false to get the old all-zero behaviour back.
///
[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);
}
///
/// One TruckPosInfo. 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.
///
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(),
RaidId = raid,
};
///
/// list7bit<u32> 解包(带游标,供一条请求里连读两个列表)。
///
/// ⛑ 首字节是集合标记:0xFF = 非空(SerializeBase.WriteList7Bit 写
/// SerializeObjectMarkCommon),0x00 = null。一个正常的项数 varint 不会以 0xFF 开头
/// (那意味着 count ≥ 2^28),所以"见到 0xFF 就跳过"是无歧义的。
/// 项数带 +1 偏置(UxWriter.Int7),与 GameRouter.Extraction.cs 的同名工具一致。
///
private static List ReadU32List7BitAt(ReadOnlySpan body, ref int pos)
{
var result = new List();
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
// -----------------------------------------------------------------------------------------
///
/// AskGetFinishedOrderWraps(63545189) — no arguments, returns the completed-log body
/// (Auto.WriteClientFinishedTruckOrderView, RPCSerializeAuto.lua:2273).
///
/// ⛑⛑ 每一行都必须带齐 AcceptInfo 和 ResultInfo,否则面板直接炸而不是显示"暂无":
/// · DeliveryLogPhonePanelStore.GetTotalMoney 读 ResultInfo.DropMoney
/// · RefreshOrderListView 读 ResultInfo.FinishTime 做排序
/// · OnRenderItem 读 ResultInfo.CargoIntegrity / Evaluation 与
/// AcceptInfo.AcceptTime 算耗时
/// · 详情 RefreshOrderDetailView(:1264) 第一句就是
/// if resultInfo ~= nil then return end(判定又是反的,详见规则 135)
///
/// 列表内容来自两处:真实结算(AskSettleTruckOrder 挪进来的行)和
/// gameplay.deliveryLogSeedCount 的历史补账。之所以要补账:客户端那条结算链
/// (PreSettleTruckOrder:571 / FinishDeliveryOrder:705) 只被任务系统驱动,
/// 面板上没有任何入口,所以在这条链接通之前,日志页只能由服务端预置内容。
/// 回滚:把 deliveryLogSeedCount 置 0 就只剩真实结算的行。
///
[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(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())}");
}
///
/// 一条「已完成」的行:照搬看板行的生成方式,再补上收货时间 / 结算结果。
///
/// 只是为了让几条补账行的时间各不相同(第 n 条是 n 小时前完成),
/// 这样列表按 FinishTime 排序和「今日收入」那一栏才看得出前后关系。
///
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(),
RemoveBuffList = new List(),
OrderDeliverUpSetId = 0u,
DeliverUpset = 0u,
};
return row;
}
///
/// 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.
///
[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);
}
/// AskStartTruckOrderGuide(63637032) — no arguments, void. Tutorial latch.
[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 就能把它读回去。
// -----------------------------------------------------------------------------------------
///
/// AskPreSettleTruckOrder(63818375) — (uint uniqueId, list TruckCargoSettleInfo) -> (TruckJobOrderWrap).
///
/// 客户端 PreSettleTruckOrder:571 的回调是 (err, truckJobOrderWrap),命中成功分支
/// 后把 info.result = truckJobOrderWrap 并 SetOrderComplete(info, true),
/// 结算面板就是靠这个 wrap 渲染的。货物完好度清单我们收下但不读 —— 那需要真实路线的里程/颠簸。
///
[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);
}
///
/// AskSettleTruckOrder(63873513) — (uint uniqueId), void.
///
/// 客户端 FinishDeliveryOrder:705 成功后关倒计时面板并推送 ON_GOOD_OPEN_INTEGRITY_PLANE,
/// 也就是评分那段演出。这里把行从看板挪进已完成列表,好让「送货日志」读得到。
///
/// ⛑⛑ 挪之前必须补 AcceptInfo + ResultInfo —— 日志面板在它们上面取字段,
/// 留 null 会直接报错而不是显示空。详见 AskGetFinishedOrderWraps 的注释。
///
[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(),
RemoveBuffList = new List(),
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);
}
///
/// AskFinishJob(63333823) — () -> void. 职业侧的"结束今日工作",与订单无关,回空即可。
///
[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);
}
///
/// AskAddTruckOrderSpecialPointRewards(63076643) — (list uint orderIds, int pointId), void.
/// 特殊点位的额外奖励领取,回空即可(奖励入账没接货币系统)。
///
[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);
}
/// First bytes of a reply, so a framing mismatch can be told apart without a client log.
/// ⛑ MUST stay generic: UxSerializer.Serialize dispatches on the STATIC type argument, so an
/// object 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 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}";
}
}
}