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