Files

373 lines
20 KiB
C#
Raw Permalink Normal View History

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;
/// <summary>
/// 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).
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// Client -&gt; 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.
/// </summary>
[Handler(MethodId.AskPublicSwitchToPublicScene)]
private async Task AskPublicSwitchToPublicScene(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.AskPublicSwitchToPublicSceneArgs>(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);
}
/// <summary>
/// Client -> server <c>EnterRaid</c> (62899289, invoke raidId/immediate) —— GM 传送面板用的
/// 原生副本切换请求。移植自捐赠端 DremkaPS(2026-09-24)。
///
/// 捐赠端硬编码两个 raid(主城 / 重霄)加一个出生点,本服改成查既有的
/// <see cref="GameAdminBridge.ScenePresets"/> —— 那份表覆盖的副本更多,落点还是 2026-09-20
/// 实机量过的;没有 preset 的 raid 按本服既有约定「接受并记日志」,不静默丢弃。
///
/// ⛑ 与同文件另两条不同,这里<b>先回 EmptyOk 再切换</b>(照捐赠端):EnterRaid 是 Invoke,
/// 客户端在 await 返回值,而 <see cref="SwitchSceneAsync"/> 要连发 SyncGamePause +
/// SyncEnterScene;先回包可以避免切换耗时把控制台那次调用拖到超时。
/// </summary>
[Handler(MethodId.EnterRaid)]
private async Task EnterRaid(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<SceneMethods.EnterRaid>(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)");
}
}
/// <summary>
/// <c>AskEnterRaidByMapEntrance</c>(63510026, invoke u32 <c>mapEntranceId</c>):
/// 景点 / 电梯传送门(<c>MapentranceConfig.Type == 1</c>)。
///
/// <h3>⛑⛑ 2026-09-30:这里只 ack 不推 <c>SyncTeleport</c>,就是「进电梯黑屏、ESC 返回不了」的根因</h3>
/// 客户端 <c>RpcUtils.AskEnterRaidByMapEntrance</c>(<c>E:\lua\lua\LX6\Utils\RpcUtils.lua:88</c>):
/// <code>
/// 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)
/// </code>
///
/// 实测(<c>packets-latest.log</c>):
/// <code>
/// 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 秒后放弃 ⇒ 黑屏
/// </code>
/// 对照同一份日志里<b>成功</b>的一次(01:07:39,大地图传送 / 地铁):
/// <c>AskPlayerChangePositionByScenePortal</c> → <c>S2C NOTIFY SyncTeleport 31b</c> →
/// <c>ReportPostTeleportFinish result=0</c>。差别就在那一帧 <c>SyncTeleport</c>。
///
/// 目的地由 <see cref="JiguanElevatorCatalog4229938"/> 从 <c>MapentranceConfig</c> 自己推出来
/// (客户端只给 Spoon 航点 id,本地没有 Spoon 图)。推不出目的地时退化成
/// 「推到入口自己那一格」——<b>仍然推</b>,因为「不推」才是黑屏。
///
/// 顺序照 <c>AskPlayerChangePositionByScenePortal</c>:先 <c>SyncTeleport</c> 再 ack。
/// 回滚 = <c>gameplay.mapEntrance.jiguanElevator=false</c>(只 ack,逐字节回到改动前)。
/// </summary>
[Handler(MethodId.AskEnterRaidByMapEntrance)]
private async Task AskEnterRaidByMapEntrance(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskEnterRaidByMapEntranceArgs>(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);
}
/// <c>SyncTeleport.MapEntranceId</c> 到底填哪个 id。
/// <c>"dest"</c>(默认)= 落点那一层的门;<c>"source"</c> = 起点门(一修旧行为);
/// <c>"none"</c> = 0(逼客户端只用 <c>Position</c>,用来验证「客户端拿 MapEntranceId 去
/// MapentranceConfig / Spoon 航点反查、本地没图所以落空」这个猜想)。
/// 落点门为 0(推导失败)时退回起点门,保证 Position 与 MapEntranceId 不打架。
/// </summary>
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,
};
}
/// <summary>
/// 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}");
}
/// </summary>
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;
}
}