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; /// /// 大地图入口(传送点 / 地铁站)—— 2026-09-27 从 kpect 捐赠端移植的「入口四连发 + 点击真传送」。 /// /// ## 为什么之前大地图上什么都没有 /// 客户端地图入口是**服务端下发驱动**的,四条 S2C 在此之前**全服零命中**(grep 过整个 v0.3): /// · SyncMapEntrance (64067363) 全量清单 → MapUtils._visibleEntrance / _unlockedEntrance /// · UpdateMapEntrance (64239821) 逐点开关 /// · SyncPrepareMapEntrance (64610549) 逐点准备(交通层入口的注册步骤) /// · SyncSubwayFare (64660858) 地铁票价 /// 判据链:MapUtils.IsEntranceVisible(id) 只查 _visibleEntrance,而这个表 /// **只能**由上面前两条帧写入 ⇒ 一条不发 = 地图上一个传送点图标都没有。 /// /// ## 点击传送 /// AskTouchMapEntrance(63284182) 是客户端 Notify(void,不等回包), /// 调用点 MapCsApi.Legacy_DoFightMapEntrance(gRaidDataManager.RaidId, mapEntranceId)。 /// 以前没有 handler ⇒ 落到 RPC 兜底 ⇒ 点了没反应。 /// · 同 raid → TeleportPlayerWithLoadingAsync(官方 loading 传送流程) /// · 跨 raid → SwitchSceneAsync(GameRouter.SceneSwitch.cs,换图事务) /// /// ## 数据来源 /// 用我们自己的 MapentranceConfig(93 行)+ RaidConfig /// (SceneCatalog4229938.MapEntrances),**不用** kpect 的 ExtraCatalog, /// 也不动主线已经完整的 GameRouter.Metro.cs(SyncRunningMetroInfos 68333359)。 /// /// ## 回滚 /// gameplay.mapEntrance.enabled=false ⇒ 四帧都不发、点击也不传送,逐字节回到移植前。 /// 逐帧也能单独关(pushSync / pushUpdate / pushPrepare / pushSubwayFare)。 /// internal sealed partial class GameRouter { /// 会话键:入口四连发只推一次(重连/换图会再走一次 world-ready)。 private const string MapEntrancePublishedKey = "map-entrance-published"; /// /// 进世界后推一次地图入口四连发。 /// /// ⛑ 派发量:93 个入口 × (UpdateMapEntrance + SyncPrepareMapEntrance) = 186 帧。 /// 收包循环是串行的(读完一帧 → await DispatchAsync → 才读下一帧),同步连发会把 /// 客户端这一秒的上报全部堵住 —— 与 PublishMapFogUnlockAsync 那次 260 帧 /// 是同一个坑,所以这里照抄它的「后台分批 + 10ms/25 帧」摊平写法。 /// internal static async Task PublishMapEntrancesAsync(RpcContext ctx) { var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance; if (!cfg.Enabled) { ctx.Session.Log.Info("[MAP-ENTRANCE] 下发=no 结果=disabled 原因=gameplay.mapEntrance.enabled=false"); return; } if (ctx.Session.Items.ContainsKey(MapEntrancePublishedKey)) return; ctx.Session.Items[MapEntrancePublishedKey] = true; var entrances = SceneCatalog4229938.MapEntrances; if (entrances.Count == 0) { ctx.Session.Log.Info("[MAP-ENTRANCE] 下发=no 结果=table-empty " + "原因=MapentranceConfig 没有可用行(RaidId=0 或无坐标的行会被 loader 丢掉)"); return; } var ids = entrances.Select(e => e.Id).Distinct().ToList(); var session = ctx.Session; if (cfg.PushSync) { await ctx.NotifyAsync(MethodId.SyncMapEntrance, new GameMethods.SyncMapEntrance4229938 { openEntrance = new List(ids), displayableEntrances = new List(ids), }); } if (cfg.PushSubwayFare) { await ctx.NotifyAsync(MethodId.SyncSubwayFare, new GameMethods.SyncSubwayFare4229938 { fare = cfg.SubwayFare, }); } var metro = entrances.Where(e => e.Type == SceneCatalog4229938.MapEntranceTypeSubway).ToList(); session.Log.Info($"[MAP-ENTRANCE] SyncMapEntrance 下发=yes 入口={ids.Count} " + $"地铁(Type=11)={metro.Count} 票价={cfg.SubwayFare} " + $"分布={string.Join(",", entrances.GroupBy(e => e.RaidId).Select(g => g.Key + ":" + g.Count()))}"); if (!cfg.PushUpdate && !cfg.PushPrepare) return; var pushUpdate = cfg.PushUpdate; var pushPrepare = cfg.PushPrepare; _ = Task.Run(async () => { const int batchSize = 25; const int batchDelayMs = 10; var sent = 0; for (var i = 0; i < ids.Count; i++) { try { if (pushUpdate) await session.NotifyAsync(MethodId.UpdateMapEntrance, UxSerializer.Serialize(new GameMethods.UpdateMapEntrance4229938 { mapEntranceId = ids[i], isOpen = true, isShow = true, }), CancellationToken.None); if (pushPrepare) await session.NotifyAsync(MethodId.SyncPrepareMapEntrance, UxSerializer.Serialize(new GameMethods.SyncPrepareMapEntrance4229938 { entrance = ids[i], }), CancellationToken.None); sent++; } catch (Exception ex) { session.Log.Warn($"[MAP-ENTRANCE] push failed entrance={ids[i]}: {ex.Message}"); break; } if ((i + 1) % batchSize == 0 && i + 1 < ids.Count) await Task.Delay(batchDelayMs, CancellationToken.None); } session.Log.Info($"[MAP-ENTRANCE] 逐点帧 update={pushUpdate} prepare={pushPrepare} 已发={sent}/{ids.Count}"); }); } /// /// AskTouchMapEntrance (63284182):客户端 void notify。命中配置就真传送。 /// /// 判据分支(grep [MAP-TOUCH]): /// ① teleport-in-place 同 raid 命中并传送 /// ② switch-raid 跨 raid 命中并换图 /// ③ unknown-id / raid-mismatch / bad-coordinate / malformed / no-world-state / disabled /// [Handler(MethodId.AskTouchMapEntrance)] private async Task AskTouchMapEntrance(Connection conn, UxRpcMessage msg) { // 客户端是 notify;万一有人用 invoke 形式发,也立刻回一个空 OK,别把协程卡住。 if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance; if (!cfg.Enabled || !cfg.TouchTeleport) { conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=disabled 原因=enabled={cfg.Enabled} touchTeleport={cfg.TouchTeleport}"); return; } if (!TryParseMapTouchBody(msg.Body, out var raidId, out var entranceId, out var parseNote)) { conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=malformed 载荷={msg.Body?.Length ?? 0}B " + "期望=8B(raidId u32 + mapEntranceId u32)"); return; } var row = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == entranceId); if (row is null) { conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=unknown-id raid={raidId} entrance={entranceId} " + $"原因=MapentranceConfig 未命中(已加载 {SceneCatalog4229938.MapEntrances.Count} 行)"); return; } if (raidId != 0 && row.RaidId != raidId) { conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=raid-mismatch 请求 raid={raidId} 配置 raid={row.RaidId} " + $"entrance={entranceId} name={row.Name}"); return; } if (!IsUsableEntranceCoordinate(row.X, row.Y, row.Z)) { conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=bad-coordinate entrance={entranceId} name={row.Name} " + $"配置坐标=({row.X:F1},{row.Y:F1},{row.Z:F1})"); return; } var state = GetStateIfExists(conn.Session); if (state is null) { conn.Log.Info("[MAP-TOUCH] 下发=no 结果=no-world-state 说明=会话里还没有世界状态(还没进过游戏)"); return; } uint currentRaid; lock (state.SyncRoot) currentRaid = state.ActiveRaidId; conn.Log.Info($"[MAP-TOUCH] stage=touch entrance={entranceId} name={row.Name} type={row.Type} " + $"入口 raid={row.RaidId} 当前 raid={currentRaid} 载荷={msg.Body?.Length ?? 0}B({parseNote}) " + $"目标=({row.X:F1},{row.Y:F1},{row.Z:F1}) facing={row.Facing:F1}"); if (row.RaidId == currentRaid) { // ① 同图:走官方 loading 传送流程(instant 那条已被 PoliceArrest.cs ⓪b 证伪:帧发了人不动)。 await TeleportPlayerWithLoadingAsync(msg.Context, row.X, row.Y, row.Z, row.Facing, $"map-touch:{entranceId}"); conn.Log.Info($"[MAP-TOUCH] 下发=yes 结果=teleport-in-place entrance={entranceId} " + $"目标=({row.X:F1},{row.Y:F1},{row.Z:F1})"); return; } if (!cfg.TouchCrossRaid) { conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=cross-raid-skipped entrance={entranceId} " + $"入口 raid={row.RaidId} 当前 raid={currentRaid} 说明=需 gameplay.mapEntrance.touchCrossRaid=true"); return; } // ② 跨图:复用既有换场景事务,落点用**被点击的那个入口**。 await SwitchSceneAsync(conn.Session, row.RaidId, row.InstanceId, row.UniverseId, row.X, row.Y, row.Z, row.Facing, $"map-touch:{entranceId}"); conn.Log.Info($"[MAP-TOUCH] 下发=yes 结果=switch-raid entrance={entranceId} raid={row.RaidId} " + $"instance={row.InstanceId} universe={row.UniverseId} 目标=({row.X:F1},{row.Y:F1},{row.Z:F1})"); } /// /// 载荷解析:主路径 = 严格 8 B(raidId @0 / mapEntranceId @4,小端裸值)。 /// 长度不为 8 时做一次自校验容错扫描(要求第二个 u32 能命中入口表、第一个与该行 RaidId 一致), /// 命中即采用并在判据行里写明「容错扫描@N」,不会静默成功。 /// private static bool TryParseMapTouchBody(byte[]? body, out uint raidId, out uint entranceId, out string note) { raidId = 0; entranceId = 0; note = "严格8B"; if (body is null || body.Length < 8) return false; if (body.Length == 8) { raidId = BitConverter.ToUInt32(body, 0); entranceId = BitConverter.ToUInt32(body, 4); return true; } for (var offset = 0; offset + 8 <= body.Length; offset++) { var candidateRaid = BitConverter.ToUInt32(body, offset); var candidateEntrance = BitConverter.ToUInt32(body, offset + 4); if (candidateEntrance == 0) continue; var hit = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == candidateEntrance); if (hit is null) continue; if (candidateRaid != 0 && hit.RaidId != candidateRaid) continue; raidId = candidateRaid; entranceId = candidateEntrance; note = $"容错扫描@{offset}"; return true; } return false; } /// /// AskPlayerChangePositionByScenePortal (67006840, invoke u32 mapEntranceId): /// 大地图「交通直达」(地铁站 / 公交站 / 传送点)**同图**传送的 domain RPC。 /// /// 客户端 MapSubSystem_Entrance.lua:559 MapTeleport 的时序: /// ① gLoadingManager:AskTeleport(loadingConfigId, preTeleportOption, ...) —— 客户端 /// 自己填好了 afterPosition(=MapTeleportPos) / afterResName(=TeleportEndTimeLine) / /// loadingResName,服务端 AskTeleport handler 只登记 + ack; /// ② ack 回来后回调里 gClientUtils.MapTeleport()(**只关 UI,不动坐标**),再按 /// 「目标 raid == 当前 raid」分流:同图 → 本 RPC;跨图 → AskPublicSwitchToPublicScene; /// ③ 本 RPC 回 Ok 才 StopLoading。 /// /// ⛑ 本 RPC 之前**连 method id 都没有**(服务端 0 处引用),又是 Invoke ⇒ 客户端 await /// 永久挂起 ⇒ 「长按 F / 点交通直达没反应」。跨图那条(AskPublicSwitchToPublicScene) /// 早就有 handler,所以只有同图(25 个地铁站绝大多数都在 23300888 内)表现为完全无响应。 /// /// 位移用 SyncTeleport 推,teleportId 取客户端自己那次 AskTeleport 登记的 /// PendingTeleportId(客户端只认自己事务的那一帧)。 /// /// 判据分支(grep [MAP-PORTAL]): /// disabled / malformed / unknown-id / bad-coordinate / no-world-state / ok。 /// [Handler(MethodId.AskPlayerChangePositionByScenePortal)] private async Task AskPlayerChangePositionByScenePortal(Connection conn, UxRpcMessage msg) { var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance; if (!cfg.Enabled || !cfg.TouchTeleport) { conn.Log.Info($"[MAP-PORTAL] 下发=no 结果=disabled 原因=enabled={cfg.Enabled} touchTeleport={cfg.TouchTeleport}"); await conn.ReturnEmptyOkAsync(msg); return; } if (!TryParsePortalBody(msg.Body, out var entranceId, out var note)) { conn.Log.Info($"[MAP-PORTAL] 下发=no 结果=malformed 载荷={msg.Body?.Length ?? 0}B 期望=4B(mapEntranceId u32)"); await conn.ReturnEmptyOkAsync(msg); return; } var row = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == entranceId); if (row is null) { conn.Log.Info($"[MAP-PORTAL] 下发=no 结果=unknown-id entrance={entranceId} " + $"原因=MapentranceConfig 未命中(已加载 {SceneCatalog4229938.MapEntrances.Count} 行)"); await conn.ReturnEmptyOkAsync(msg); return; } if (!IsUsableEntranceCoordinate(row.X, row.Y, row.Z)) { conn.Log.Info($"[MAP-PORTAL] 下发=no 结果=bad-coordinate entrance={entranceId} name={row.Name} " + $"配置坐标=({row.X:F1},{row.Y:F1},{row.Z:F1})"); await conn.ReturnEmptyOkAsync(msg); return; } var state = GetStateIfExists(conn.Session); if (state is null) { conn.Log.Info("[MAP-PORTAL] 下发=no 结果=no-world-state 说明=会话里还没有世界状态(还没进过游戏)"); await conn.ReturnEmptyOkAsync(msg); return; } ulong teleportId; uint currentRaid; lock (state.SyncRoot) { currentRaid = state.ActiveRaidId; teleportId = state.PendingTeleportId; state.PendingTeleportSyncSent = true; state.LastReportedPlayerPosition = new Vec3(row.X, row.Y, row.Z); state.LastReportedPlayerRotation = new Vec3(0f, row.Facing, 0f); state.HasLastReportedPlayerTransform = true; } await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption { teleportId = teleportId, Position = new GameMethods.TeleportVec3(row.X, row.Y, row.Z), Facing = row.Facing, IsSwitchScene = false, WaitTaskResource = false, MapEntranceId = entranceId, }); conn.Log.Info($"[MAP-PORTAL] 下发=yes 结果=teleport-in-place entrance={entranceId} name={row.Name} type={row.Type} " + $"入口 raid={row.RaidId} 当前 raid={currentRaid} teleportId={teleportId} 载荷={msg.Body?.Length ?? 0}B({note}) " + $"目标=({row.X:F1},{row.Y:F1},{row.Z:F1}) facing={row.Facing:F1}"); await conn.ReturnEmptyOkAsync(msg); } /// /// 载荷解析:主路径 = 严格 4 B(mapEntranceId u32 小端裸值)。 /// 长度不为 4 时做一次自校验容错扫描(要求该 u32 能命中入口表),命中即采用并写明 /// 「容错扫描@N」,不会静默成功。 /// private static bool TryParsePortalBody(byte[]? body, out uint entranceId, out string note) { entranceId = 0; note = "严格4B"; if (body is null || body.Length < 4) return false; if (body.Length == 4) { entranceId = BitConverter.ToUInt32(body, 0); return true; } for (var offset = 0; offset + 4 <= body.Length; offset++) { var candidate = BitConverter.ToUInt32(body, offset); if (candidate == 0) continue; if (SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == candidate) is null) continue; entranceId = candidate; note = $"容错扫描@{offset}"; return true; } return false; } /// 坐标可用性:有限数且不在原点(表里有 (0,0,0) 占位行)。 private static bool IsUsableEntranceCoordinate(float x, float y, float z) => float.IsFinite(x) && float.IsFinite(y) && float.IsFinite(z) && (MathF.Abs(x) > 0.001f || MathF.Abs(z) > 0.001f); }