using Ananta.SDK.Rpc; 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; /// /// 赛车「赛事车辆 + 检查点推进」两个 C2S 回执(R217, 2026-10-01)。 /// /// WHY THIS FILE EXISTS /// 这两个包在 R216 之前**从来没有 handler**(Handlers 里连名字都没有,只有 MethodId 常量), /// 于是它们落到 DefaultUnknownInvoke4229938 的 typed-default 兜底:客户端拿到 err=0 的**假 OK** /// 就继续跑。表面无害,实际有两个代价: /// 1. 服务端**完全不知道**玩家选了哪条赛道 / 哪辆车,也不知道检查点走到第几个 —— 结算、AI 路线 /// 和任何防作弊校验都没有数据来源,只能重新从 raidId 反推。 /// 2. 一旦哪天客户端在回调里读回执的**真实载荷**(而不是只看 err),typed-default 的空体立刻变成 /// 「字段全 0」的伪数据,而那时故障点会跑到客户端里去,比在这里显式回一句更难查。 /// /// 所以本轮把它们补成「显式 ack + 落日志」,语义与 typed-default 完全一致(都是 void → 空 OK), /// 但服务端从此握有真相。不改变任何既有行为:注册前是兜底回 0,注册后是显式回 0。 /// /// 线格式(客户端 generated writer,LuaGen/AutoGen/ClientToGameDelegate.lua,逐字节核对过): /// /// AskSetRacingCompetitionVehicle 63764162 /// SerializerHelper.AskSetRacingCompetitionVehicle_Serializer(writer, trackid, vehicleid) /// WriteUInt32(trackid) + WriteUInt32(vehicleid) = 8 B /// 客户端调用点:RacerManager.lua:208 gClientToGameDelegate:AskSetRacingCompetitionVehicle(trackId, vehicleId) /// /// AskSetTaskCounterValue 63753212 /// SerializerHelper.AskSetTaskCounterValue_Serializer(writer, taskid, counterindex, value) /// WriteUInt32(taskid) + WriteInt32(counterindex) + WriteInt32(value) = 12 B /// 客户端调用点:CarRaceManager.lua:204 / :247 / :431(过检查点、换圈、终点) /// /// /// ★ 这两个日志是**判读检查点是否真的在推进**的判据(R216 的结论:检查点 = taskCounterList = /// SpoonTaskMgr 的 workActions,空图 ⇒ 永不上报)。白天实测时: /// * 出现 [RACE][COUNTER] ⇒ 客户端真的在过检查点 ⇒ 任务图装载成功; /// * 一条都没有 ⇒ 图仍为空(workActions=0),检查点这条线依旧死。 /// /// ⛑ 回滚:删掉 GameRouter.cs 的 Enabled4229938MethodIds 里对应的两行即可(回到 typed-default 兜底), /// 本文件保留无害。 /// internal sealed partial class GameRouter { /// /// AskSetRacingCompetitionVehicle(63764162) — 玩家在挑战面板选定的赛事车辆。 /// /// 触发时机:RacerManager.TrySetCompetitionVehicle 走完所有早退分支之后。★ 反过来说, /// 这条日志**出现与否本身就是「门开没开」的判据**:R216 定案该函数有两个**不打日志**的早退分支 /// (trackId 无效 / 没有赛车手职业),以及一个打日志的「未找到玩家载具」分支。现在把门修好了 /// (urbanJobOverrides 给 15020967 加了 job 4),这条日志应当开始出现。 /// [Handler(MethodId.AskSetRacingCompetitionVehicle, HandlerPacketKind.Invoke)] private async Task AskSetRacingCompetitionVehicleAccept(Connection conn, UxRpcMessage msg) { var b = msg.Body; var trackId = b.Length >= 4 ? ReadU32(b, 0) : 0u; var vehicleId = b.Length >= 8 ? ReadU32(b, 4) : 0u; conn.Log.Info( $"[RACE][VEHICLE] AskSetRacingCompetitionVehicle track={trackId} vehicle={vehicleId} " + $"bytes={b.Length} (>=1 条即说明客户端已越过职业门)"); // 2026-10-01 R217: 协议里该 RPC 的回执是 void(客户端 Callback 只读 err),空 OK 即完整语义。 await conn.ReturnEmptyOkAsync(msg); } /// /// AskSetTaskCounterValue(63753212) — 检查点计数器上报。 /// /// counterIndex 在客户端是**下标**语义(CarRaceManager.lua 传的是 rankPointIndex-1 之类), /// value 是 0/1(0=离开,1=到达)。这里原样记录,不做校验 —— 检查点状态由客户端自己 /// 维护 taskCounterList,服务端目前没有可与之对齐的权威表(任务图的 workActions 才是, /// 而它取决于 Spoon 图是否装载成功)。 /// /// ★ R234(2026-10-01):本 handler 不再只是「记录」。`index == RacingClientCheckpointCount` /// 的那一条 = 客户端**跑完全程**的完成上报(见方法内注释),它会立刻叫醒 /// `RunRacingAutoStartAsync` 里等待的那局 ⇒ 推 `GAMEOVER` + `SyncRacingResultData`。 /// 这就是「服务端接进结算逻辑」——R217 写下这段注释时留的那个挂点。 /// [Handler(MethodId.AskSetTaskCounterValue, HandlerPacketKind.Invoke)] private async Task AskSetTaskCounterValueAccept(Connection conn, UxRpcMessage msg) { var b = msg.Body; var taskId = b.Length >= 4 ? ReadU32(b, 0) : 0u; var counterIndex = b.Length >= 8 ? unchecked((int)ReadU32(b, 4)) : 0; var value = b.Length >= 12 ? unchecked((int)ReadU32(b, 8)) : 0; conn.Log.Info( $"[RACE][COUNTER] AskSetTaskCounterValue task={taskId} index={counterIndex} " + $"value={value} bytes={b.Length}"); // ★ R294:记住本局最后上报到的下标 —— 结算日志会带出来,用来区分 // 「终点判据坏了」和「300s 到点没跑完」。见 TakeRacingLastCounter 的说明。 if (value != 0) NoteRacingCounter(taskId, counterIndex); // ★★★ R234:「跑完全程」的唯一权威信号 ⇒ 立刻结算(不再干等 MaxGameTime=300s)。 // // 客户端证据(`CarRaceManager.lua:201-204`,19:03/19:05 两轮实测均在日志中出现): // 通过**最后一个检查点**时,`OnCurrentTaskChanged` 走完成分支并上报 // `AskSetTaskCounterValue(taskID, self.taskCounterCount, 1)` // —— 即 `index == taskCounterCount`(= 客户端的检查点总数,见 // `RacingClientCheckpointCount`)。**这是整条链里唯一一处用 `taskCounterCount` // 当 index 上报的地方**(另一处 `:431` 发 `nextRankPointIndex - 1` = 普通检查点 // 的 0..N-1),所以两者不会混淆。 // // 同时它也是客户端**关掉赛车 HUD 面板**的那一刻 // (完成分支里的 `gPanelManager:Close(gPanelId.S_RACING_PANEL)`)—— // 在旧实现下,玩家看到的是「面板自己消失了,然后什么都没有」, // 这正是用户报的「没有办法到终点结束」。 if (value != 0 && counterIndex == RacingClientCheckpointCount) { var wokeLiveRace = RequestRacingSettle(taskId); conn.Log.Info( $"[RACE][COUNTER] ** FINISH ** task={taskId} index={counterIndex}" + $" == RacingClientCheckpointCount({RacingClientCheckpointCount})" + $" -> settle signal {(wokeLiveRace ? "FIRED (GAMEOVER + SyncRacingResultData 立即推)" : "FIRED-NOWHERE (没有在跑的比赛:已结过账 / 不是本进程启动的)")}"); } // ★★★★★ R294(2026-10-02)—— **过点即撤门**。用户原话:「检查点开过去不会消失」。 // // 我们一局 spawn 52 个门(26 点 × A/B 两半)后**从不回收**;而官方的检查点标记 // 只有一个、跟着当前目标走(`CarRaceManager.arrowGO`:`AddArrow` 建、`RemoveArrow` 销毁) // ⇒ 玩家看到的「过点即消失」在官方那边是**客户端自建物**的行为,我们这份是服务端 gadget, // 不主动撤就永远在。撤哪几个见 `GameRouter.RacingGates` 的 R294 段: // // **上报的 index 恰好等于我们自己的检查点下标** —— 客户端 `taskCounterList` 就是我们 Lua // 喂的 `R255_OFFICIAL_CP`(`racing_guard.lua:1077-1080, 1211`),过 `list[k+1]`(= 我们的 // cp k)时 `UpdateEntityRankInfoById` 发 `nextRankPointIndex - 1 = k`。 // 跑完全程时走 `OnCurrentTaskChanged` 的完成分支,发 `taskCounterCount` // (= 26 = `RacingClientCheckpointCount`)⇒ 那一条触发**全清**。 // // 闸门:`racingGates.removeOnPass`(默认 true)。关掉 = 回到 R293 的「一局只摆不清」。 if (value != 0 && counterIndex >= 0 && PrivateServerConfigStore.Current.World.Content.RacingGates.RemoveOnPass) { try { var st = GetStateStatic(conn.Session); if (counterIndex >= RacingClientCheckpointCount) { await RemoveAllRacingGatesAsync(conn.Session, st); } else { await RemoveCheckpointGatesAsync(conn.Session, st, counterIndex); } } catch (Exception ex) { // 撤门失败绝不能拖垮计数器回执 —— 门留在原地只是观感问题。 conn.Log.Warn($"[RACEGATE] remove-on-pass failed cp={counterIndex}: {ex.Message}"); } } await conn.ReturnEmptyOkAsync(msg); } }