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 Ananta.Server.RpcTypes.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// Scene ("raid") switching: re-push SyncEnterScene with a new raid/instance/universe /// destination and re-arm the world-entry barrier lifecycle so the client's normal /// AskLoadSceneCompleted → quartet → AskLoadingFinished → movement flow runs again. /// Ported from the proven newcityps (4091149) airport handoff, adapted to the minimal /// 4229938 world entry (no timelines, no AOI streaming). /// internal sealed partial class GameRouter { /// /// Client -> server map/teleport request: AskPublicSwitchToPublicScene /// (63491547, invoke raidId/delay/mapEntranceId). Known raid ids switch via the /// preset arrivals; anything else is accepted and logged for future mapping. /// [Handler(MethodId.AskPublicSwitchToPublicScene)] private async Task AskPublicSwitchToPublicScene(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { conn.Log.Info($"[SCENE] AskPublicSwitchToPublicScene raid={args.raidId} delay={args.delay} entrance={args.mapEntranceId}"); if (args.raidId != 0 && GameAdminBridge.TryGetScenePreset(args.raidId, out var preset) && preset is not null) await SwitchSceneAsync(msg.Context.Session, preset.RaidId, preset.InstanceId, preset.UniverseId, preset.X, preset.Y, preset.Z, preset.Facing, "client-public-switch"); } else { conn.Log.Warn($"[SCENE] AskPublicSwitchToPublicScene undecodable bytes={msg.Body.Length}"); } await conn.ReturnEmptyOkAsync(msg); } /// /// Client -> server EnterRaid (62899289, invoke raidId/immediate) —— GM 传送面板用的 /// 原生副本切换请求。移植自捐赠端 DremkaPS(2026-09-24)。 /// /// 捐赠端硬编码两个 raid(主城 / 重霄)加一个出生点,本服改成查既有的 /// —— 那份表覆盖的副本更多,落点还是 2026-09-20 /// 实机量过的;没有 preset 的 raid 按本服既有约定「接受并记日志」,不静默丢弃。 /// /// ⛑ 与同文件另两条不同,这里先回 EmptyOk 再切换(照捐赠端):EnterRaid 是 Invoke, /// 客户端在 await 返回值,而 要连发 SyncGamePause + /// SyncEnterScene;先回包可以避免切换耗时把控制台那次调用拖到超时。 /// [Handler(MethodId.EnterRaid)] private async Task EnterRaid(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) conn.Log.Info($"[SCENE] EnterRaid raid={args.raidId} immediate={args.immediate}"); else conn.Log.Warn($"[SCENE] EnterRaid undecodable bytes={msg.Body.Length}"); await conn.ReturnEmptyOkAsync(msg); if (args is null) return; if (args.raidId != 0 && GameAdminBridge.TryGetScenePreset(args.raidId, out var preset) && preset is not null) { await SwitchSceneAsync(msg.Context.Session, preset.RaidId, preset.InstanceId, preset.UniverseId, preset.X, preset.Y, preset.Z, preset.Facing, "client-enter-raid"); } else { conn.Log.Warn($"[SCENE] EnterRaid raid={args.raidId} has no preset (accepted, no switch)"); } } /// /// AskEnterRaidByMapEntrance(63510026, invoke u32 mapEntranceId): /// 景点 / 电梯传送门(MapentranceConfig.Type == 1)。 /// ///

⛑⛑ 2026-09-30:这里只 ack 不推 SyncTeleport,就是「进电梯黑屏、ESC 返回不了」的根因

/// 客户端 RpcUtils.AskEnterRaidByMapEntrance(E:\lua\lua\LX6\Utils\RpcUtils.lua:88): /// /// AskTeleport(LoadingConfig.EnterExitRoomId=2, option, extParams, cb) /// option.beforeResName = cfg.JiguanStartTimeline -- "loading_elevator02" /// option.beforePosition = cfg.Coordinate -- 电梯门自己那一格 /// option.afterResName = cfg.JiguanEndTimeline -- "loading_general_end" /// option.afterPosition = (0,0,0) -- ★ 客户端不知道目的地 /// option.customAfterTrans = false -- ★ 所以不会自己套 afterPosition /// cb(= ReportPreTeleportFinish 被 ack 之后)→ gClientToGameDelegate:AskEnterRaidByMapEntrance(id) /// /// /// 实测(packets-latest.log): /// /// 01:07:55.716 C2S AskTeleport config=2 before=loading_elevator02 beforePosition=(2257.781,0,1458.242) /// 01:07:55.717 S2C RETURN AskTeleport e=0 /// 01:07:55.717 S2C NOTIFY SyncPreTeleportOption 146b ← EchoPreTeleport 回显成功 /// 01:08:01.359 C2S ReportPreTeleportFinish {"teleportId":0} ← 过场动画播完 /// 01:08:01.362 C2S AskEnterRaidByMapEntrance {"mapEntranceId":337} /// 01:08:01.366 S2C RETURN AskEnterRaidByMapEntrance e=0 ← 只 ack,没有 SyncTeleport /// 01:09:30.020 C2S ReportPostTeleportFinish {"result":2} ← 86 秒后放弃 ⇒ 黑屏 /// /// 对照同一份日志里成功的一次(01:07:39,大地图传送 / 地铁): /// AskPlayerChangePositionByScenePortal → S2C NOTIFY SyncTeleport 31b → /// ReportPostTeleportFinish result=0。差别就在那一帧 SyncTeleport。 /// /// 目的地由 从 MapentranceConfig 自己推出来 /// (客户端只给 Spoon 航点 id,本地没有 Spoon 图)。推不出目的地时退化成 /// 「推到入口自己那一格」——仍然推,因为「不推」才是黑屏。 /// /// 顺序照 AskPlayerChangePositionByScenePortal:先 SyncTeleport 再 ack。 /// 回滚 = gameplay.mapEntrance.jiguanElevator=false(只 ack,逐字节回到改动前)。 ///
[Handler(MethodId.AskEnterRaidByMapEntrance)] private async Task AskEnterRaidByMapEntrance(Connection conn, UxRpcMessage msg) { if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[SCENE] AskEnterRaidByMapEntrance undecodable bytes={msg.Body.Length}"); await conn.ReturnEmptyOkAsync(msg); return; } var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance; if (!cfg.Enabled || !cfg.JiguanElevator) { conn.Log.Info($"[JIGUAN] 下发=no 结果=disabled entrance={args.mapEntranceId} " + $"原因=enabled={cfg.Enabled} jiguanElevator={cfg.JiguanElevator}"); await conn.ReturnEmptyOkAsync(msg); return; } var state = GetStateIfExists(conn.Session); if (state is null) { conn.Log.Info($"[JIGUAN] 下发=no 结果=no-world-state entrance={args.mapEntranceId} " + "说明=会话里还没有世界状态(还没进过游戏)"); await conn.ReturnEmptyOkAsync(msg); return; } if (!JiguanElevatorCatalog4229938.TryResolve(args.mapEntranceId, cfg.JiguanGroupRadius, cfg.JiguanLandingOverrides, out var dest, out var note)) { conn.Log.Info($"[JIGUAN] 下发=no 结果=unknown-id entrance={args.mapEntranceId} {note}"); await conn.ReturnEmptyOkAsync(msg); return; } uint currentRaid; ulong teleportId; lock (state.SyncRoot) { currentRaid = state.ActiveRaidId; teleportId = state.PendingTeleportId; state.PendingTeleportSyncSent = true; state.LastReportedPlayerPosition = new Vec3(dest.X, dest.Y, dest.Z); state.LastReportedPlayerRotation = new Vec3(0f, dest.Facing, 0f); state.HasLastReportedPlayerTransform = true; } // 跨 raid 的景点门(301/303/320/334 副本、335/336 机场、355-358 凌云)需要换图事务, // 那是另一条链路(SyncEnterScene + world-entry barrier),本轮不动。 // 但仍然推一帧「原地」SyncTeleport:宁可「电梯没动」,也不能停在黑屏。 if (dest.RaidId != 0 && dest.RaidId != currentRaid) { var anchor = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == args.mapEntranceId); var sx = anchor?.X ?? dest.X; var sy = anchor?.Y ?? dest.Y; var sz = anchor?.Z ?? dest.Z; await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption { teleportId = teleportId, Position = new GameMethods.TeleportVec3(sx, sy, sz), Facing = dest.Facing, IsSwitchScene = false, WaitTaskResource = false, MapEntranceId = args.mapEntranceId, }); conn.Log.Warn($"[JIGUAN] 下发=partial 结果=cross-raid-stay entrance={args.mapEntranceId} " + $"目标 raid={dest.RaidId} 当前 raid={currentRaid} " + $"落点=({sx:F2},{sy:F2},{sz:F2}) 说明=跨图换场景本轮未实现,只解开 loading(不再是黑屏)"); await conn.ReturnEmptyOkAsync(msg); return; } // ⛑⛑ 2026-09-30 二修:MapEntranceId 必须填「目的地那一层的门」,不能填玩家按下的起点门。 // 客户端 CommonFuncTeleportFlowInfo 的 ReParse_AfterData() 不吃参数,只认服务端推下来的 // teleportOption;本链路客户端自己的 afterPosition 是全零(customAfterTrans=false), // 所以它只能拿 MapEntranceId 去 MapentranceConfig 反查落点 ⇒ 填 337 就回到 337 那一格 = 原地。 // 对照成功链路(大地图传送 AskPlayerChangePositionByScenePortal):那里填的就是目的地入口 id。 // 两个来源(Position / MapEntranceId)现在都指向同一扇门,走哪条分支都对。 var entranceId = JiguanEntranceIdToSend(cfg, args.mapEntranceId, dest); // ★ 2026-09-30 四修:记下这趟电梯的落点。实测客户端不采纳这一帧([INDOOR] 三次都报 // 起点大厅),所以 ReportPostTeleportFinish 会用硬设位置通道再补最后一帧。 // 见 MapEntranceSettings.JiguanHardSet。 conn.Session.Items[PendingJiguanLandingKey] = new PendingJiguanLanding(dest.X, dest.Y, dest.Z, dest.Facing, args.mapEntranceId, dest.EntranceId); await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption { teleportId = teleportId, Position = new GameMethods.TeleportVec3(dest.X, dest.Y, dest.Z), Facing = dest.Facing, IsSwitchScene = false, WaitTaskResource = false, MapEntranceId = entranceId, }); conn.Log.Info($"[JIGUAN] 下发=yes 结果=teleport entrance={args.mapEntranceId} " + $"下发门={entranceId}(mode={cfg.JiguanEntranceIdMode}) " + $"resolved={dest.Resolved} teleportId={teleportId} 当前 raid={currentRaid} " + $"落点=({dest.X:F2},{dest.Y:F2},{dest.Z:F2}) facing={dest.Facing:F0} {note}"); await conn.ReturnEmptyOkAsync(msg); } /// SyncTeleport.MapEntranceId 到底填哪个 id。 /// "dest"(默认)= 落点那一层的门;"source" = 起点门(一修旧行为); /// "none" = 0(逼客户端只用 Position,用来验证「客户端拿 MapEntranceId 去 /// MapentranceConfig / Spoon 航点反查、本地没图所以落空」这个猜想)。 /// 落点门为 0(推导失败)时退回起点门,保证 Position 与 MapEntranceId 不打架。 /// private static uint JiguanEntranceIdToSend( MapEntranceSettings cfg, uint sourceEntranceId, JiguanDestination4229938 dest) { if (dest.EntranceId == 0) return sourceEntranceId; return (cfg.JiguanEntranceIdMode ?? string.Empty).Trim().ToLowerInvariant() switch { "source" => sourceEntranceId, "none" => 0u, _ => dest.EntranceId, }; } /// /// Server-driven scene switch. Re-arms the world-entry transaction for a new /// generation, then pushes SyncEnterScene. The existing barrier handlers /// (OnLoadSceneCompleted / OnLoadingFinished / movement) complete the handoff. internal static async Task<(bool Ok, string Message)> SwitchSceneDirectAsync( TcpSession session, uint raidId, ulong instanceId, uint universeId, Vec3 position, float facing, bool isAirport = false) { await SwitchSceneAsync(session, raidId, instanceId, universeId, position.X, position.Y, position.Z, facing, isAirport ? "airport" : "direct-debug"); return (true, $"switched to raid={raidId} instance={instanceId} universe={universeId}"); } /// internal static async Task SwitchSceneAsync( TcpSession session, uint raidId, ulong instanceId, uint universeId, float x, float y, float z, float facing, string reason) { var state = GetStateStatic(session); // 换场景:机械羊 / 子弹时间都属于旧世界,必须在 SyncEnterScene 之前收回。 // 两者的 runtime 各带 WorldEntryControlGeneration 守卫,这里是即时回收那一层。 await StopMechanikaSheep4229938(session); // ★ 捐赠端 GameRouter.SceneSwitch.cs:63:换场景要连 S002 root 一起删。 await StopLixiPolice4229938(session, sceneChange: true); await StopBaiJingTime4229938(session, "switch-scene"); ulong unitId; uint templateId; int generation; lock (state.SyncRoot) { // Landing inside the scene the client is already running: SyncEnterScene reloads the // level but does not move the avatar (see WorldEntryState.SceneLandingPosition), so the // pose is captured here and re-applied as a real move after the entry flow goes ready. var sameScene = state.ActiveRaidId == raidId && state.ActiveInstanceId == instanceId; state.SceneLandingPosition = sameScene ? new Vec3(x, y, z) : null; state.SceneLandingFacing = sameScene ? facing : null; state.ActiveRaidId = raidId; state.ActiveInstanceId = instanceId; state.ActiveUniverseId = universeId; state.ActiveContentScene = raidId == Profile.RaidId ? "WorldMap_Release" : $"raid-{raidId}"; unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; templateId = state.ActiveSpiritTemplateId != 0 ? state.ActiveSpiritTemplateId : Profile.InitialSpiritTemplateId; var next = state.WorldEntryControlGeneration + 1; if (next <= 0) next = 1; state.WorldEntryControlGeneration = next; generation = next; state.WorldEntryControlPending = true; state.WorldEntryControlFinalized = false; state.WorldEntryControlUnit = unitId; state.WorldEntryControlTemplate = templateId; // A switch can happen on any owned character, not just the initial one. state.WorldEntryAllowNonInitialControl = true; state.WorldEntryLoadingCompletedGeneration = 0; state.WorldEntryOpeningEndedGeneration = 0; state.WorldEntryControlFinalizingGeneration = 0; state.WorldEntryLogicProjectionPublishedGeneration = 0; state.WorldEntryCurrentMetadataPublishedGeneration = 0; state.WorldEntrySceneId4229938 = 0; state.WorldEntrySessionId4229938 = 0; // ★ R101:声明本次要等的 scene(== instanceId,见 SceneCatalog4229938: // raid 23300888→20001222 / 23300999→20001223)。OnLoadSceneCompleted 用它 // 丢弃「上一张图迟到的 ack」,否则那个 ack 会占住权威位、把新图的 ack // 判成 correlation-mismatch ⇒ world-entry 永不 finalize ⇒ 所有摆件不刷。 state.WorldEntryExpectedSceneId = instanceId; state.WorldEntryCreateHeroPosition = new Vec3(x, y, z); state.WorldEntryCreateHeroFacing = facing; state.LastReportedPlayerPosition = new Vec3(x, y, z); state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f); state.HasLastReportedPlayerTransform = true; state.SceneLoadAckSeen = false; state.GameResourcesReadySeen = false; state.ControlPublished = false; state.SceneCompletionSent = false; state.CurrentSpiritStateSent = false; state.Ready = false; state.SameSceneAckSeen = false; state.MovementCapabilityPublished = false; state.FreeRoamReleased = false; state.FirstMovementSeen = false; state.InitialCapabilityBuffsPublished = false; state.AllBuildBuffsPublished = false; state.InitialActorPresentationPublished = false; state.CombatProfilePublished = false; state.SceneId = 0; state.PendingTeleportId = 0; state.PendingTeleportPreFinished = false; state.PendingTeleportSyncSent = false; state.WorldEntryIsAirportTravel = false; state.PendingSwitchTemplateId = 0; state.PendingSwitchUnitId = 0; state.PendingSwitchOldUnitId = 0; state.PendingSwitchControlTransferred = false; state.PendingSwitchLandingStarted = false; } await ForceLeaveVehicleAsync(session); GetVehicleRuntimeState(session).Clear(); await session.NotifyAsync(MethodId.SyncGamePause, UxSerializer.Serialize(WorldCodec.GamePause(true)), CancellationToken.None); await session.NotifyAsync(MethodId.SyncEnterScene, UxSerializer.Serialize(RuntimePayloadFactory.EnterScene( raidId, instanceId, universeId, unitId, templateId, new Vec3(x, y, z), facing, switchShowId: 0, isSwitchSpiritShow: false)), CancellationToken.None); session.Log.Info($"[SCENE] switch reason={reason} generation={generation} raid={raidId} instance={instanceId} universe={universeId} unit={unitId} template={templateId} pos=({x:F2},{y:F2},{z:F2}) facing={facing:F2}"); // ⛑ 换图本身就是一次确定的「我在哪」变更 —— 立刻落盘,别等玩家走够 40m / 20s。 // 2026-09-27:只靠移动上报节流保存的话,「切到重霄站着看两眼就下线」这类会 // 丢掉整段进度,重登回出生点。搜打撤副本由 IsPersistableRaidForPosition 拦掉, // 副本里退出时写回的是进副本前的那份坐标(PlayerProgressStore.Save 的 else 分支)。 PlayerProgressStore.Save(state, session.Log, quiet: true); } private static WorldEntryState GetStateStatic(TcpSession session) { if (session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing) return existing; var created = new WorldEntryState(); session.Items[WorldStateKey] = created; return created; } }