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}"; } } }