374 lines
20 KiB
C#
374 lines
20 KiB
C#
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 -> 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;
|
||
}
|
||
}
|