697 lines
36 KiB
C#
697 lines
36 KiB
C#
using Ananta.SDK.Rpc;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.Handlers;
|
||
using Ananta.Server.Gameplay;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.ClientData.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>World entry and client-driven scene loading barriers.</summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
WorldEntryState GetWorldState(RpcContext ctx)
|
||
{
|
||
if (ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing)
|
||
return existing;
|
||
|
||
var created = new WorldEntryState();
|
||
// 改装件读盘(开关 repairShop.persistTuning,默认关)。只在 state 首次创建时读一次,
|
||
// 之后由 AskBuyModifyParts 直接写内存并在每次购买后落盘。
|
||
VehicleTuningStore.Load(Profile.PlayerPid, created);
|
||
ctx.Session.Items[WorldStateKey] = created;
|
||
return created;
|
||
}
|
||
|
||
void ArmWorldEntry4229938(WorldEntryState state)
|
||
{
|
||
lock (state.SyncRoot)
|
||
{
|
||
var next = state.WorldEntryControlGeneration + 1;
|
||
if (next <= 0)
|
||
next = 1;
|
||
|
||
state.WorldEntryControlGeneration = next;
|
||
state.WorldEntryControlPending = true;
|
||
state.WorldEntryControlFinalized = false;
|
||
state.WorldEntryControlUnit = Profile.InitialUnitId;
|
||
state.WorldEntryControlTemplate = Profile.InitialSpiritTemplateId;
|
||
state.WorldEntryLoadingCompletedGeneration = 0;
|
||
state.WorldEntryOpeningEndedGeneration = 0;
|
||
state.WorldEntryControlFinalizingGeneration = 0;
|
||
state.WorldEntryLogicProjectionPublishedGeneration = 0;
|
||
state.WorldEntryCurrentMetadataPublishedGeneration = 0;
|
||
state.WorldEntrySceneId4229938 = 0;
|
||
state.WorldEntrySessionId4229938 = 0;
|
||
// ★ R101:首次进世界不预设 scene(任何 ack 都接受,保持原行为)。
|
||
state.WorldEntryExpectedSceneId = 0;
|
||
state.WorldEntryCreateHeroPosition = Profile.WorldSpawn;
|
||
state.WorldEntryCreateHeroFacing = Profile.WorldFacing;
|
||
state.LastReportedPlayerPosition = Profile.WorldSpawn;
|
||
state.LastReportedPlayerRotation = new Vec3(0f, Profile.WorldFacing, 0f);
|
||
state.HasLastReportedPlayerTransform = false;
|
||
state.LastSwitchShowId = 0;
|
||
state.AllBuildBuffsPublished = false;
|
||
state.MapFogUnlockPublished = false;
|
||
state.PlayerTasksPublished = false;
|
||
state.InitialActorPresentationPublished = false;
|
||
state.ActiveSpiritUnitId = Profile.InitialUnitId;
|
||
state.ActiveSpiritTemplateId = Profile.InitialSpiritTemplateId;
|
||
// ★ 登录时操控的那个角色也算「被玩家操控过」—— 第一次切走他时同样不去过日子,
|
||
// 停在玩家离开他的地方(WorldEntryState.ControlledSpirits)。
|
||
state.MarkSpiritControlled(Profile.InitialSpiritTemplateId);
|
||
state.WorldEntryIsAirportTravel = false;
|
||
}
|
||
}
|
||
|
||
async Task SendInitialGameState(RpcContext ctx)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
if (state.InitialStateSent)
|
||
{
|
||
ctx.Session.Log.Info("[WORLD-MIN] LoginGame repeat ignored");
|
||
return;
|
||
}
|
||
state.InitialStateSent = true;
|
||
|
||
// Reload the persisted wallet/backpack/gacha counters before the login payload is built,
|
||
// so money and items granted in a previous session are published with PlayerInfo.
|
||
RestorePlayerProgress4229938(state, ctx.Session.Log);
|
||
|
||
await ctx.NotifyAsync(MethodId.SendServerTimeGame, LoginCodec.ServerTime());
|
||
|
||
// ⛑ 搜打撤的背包必须在<b>登录帧构造之前</b>建好:PlayerExtractionShooterInfo 就在
|
||
// SyncPlayerInfo 那一帧里走,晚一步这一帧就是空的,而客户端不会为此再问一次。
|
||
// 只是往 session.Items 里塞几个空包 —— 零推送,唯一开销是那 11 张 json 的 Lazy 解析。
|
||
var extraction = EnsureExtractionSeeded(ctx.Session);
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncPlayerInfo,
|
||
RuntimePayloadFactory.MinimalPlayerInfo4229938(state, extraction));
|
||
|
||
// OSS 图床配置不再等到玩家打开趴卜才推。眼界 (Tuite) 的配图走同一个 AliOssManager
|
||
// (SocialNetWorkUtils.lua:2202 store.bucketName = GetOssPostImage(unpack(imageList[1].imageURL))
|
||
// / YanjieNewDetailPagePanel.lua:494 SetOssObjectWithCallback),但眼界**没有任何拉列表的
|
||
// RPC** —— 列表全在登录帧的 PlayerTuiteInfo.TuiteList 里,所以不会像趴卜那样有一个
|
||
// AskMomentsPostSimpleInfos 可以挂在后面推配置。先开眼界再开趴卜的玩家会一直拿不到
|
||
// bucket/endpoint,配图全部静默失败。登录帧之后推一次就两种 app 都覆盖了。
|
||
await PublishOssBucketInfoOnceAsync(new Connection(ctx.Session, ctx.CancellationToken), ctx.Session);
|
||
|
||
await PublishVehicleOwnership(ctx);
|
||
// ⛑️ grant-all 已从 LoginGame 关键路径移除(2026-09-23)。
|
||
// 它一次推 671 个 SyncArmoryAddWeapon(batchSize=25 + 10ms),会把 LoginGame handler
|
||
// 占住几百毫秒;客户端重连时 RequestPlayerStopHangup 因此 5 秒收不到应答
|
||
// ⇒ Reconnect Module 12 秒硬超时(console-20260922-234914.log 里 19 分钟 LoginGame 9 次)。
|
||
// 现在只在场景加载完成后发 —— 见下方 OnLoadingFinished 里的 PublishAllAccountWeaponsIfEnabled。
|
||
// 语义不变:每次场景加载仍重放一次(OnLoadingFinished 里的 ResetAllWeaponsSentFlag 照常重置闸门),
|
||
// 但一次进世界的包数从 1342 降到 671。代价:加载画面那几秒武器不在,无人可感。
|
||
await SendEnterSceneIfNeeded(ctx);
|
||
}
|
||
|
||
|
||
async Task SendEnterSceneIfNeeded(RpcContext ctx)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
if (state.EnterSceneSent)
|
||
return;
|
||
|
||
state.EnterSceneSent = true;
|
||
ArmWorldEntry4229938(state);
|
||
state.WorldEntrySwitchShowId = 0;
|
||
|
||
// Replay where the player was standing when the snapshot was written — including the scene
|
||
// they were in. A coordinate captured in an unresolvable scene (or a hand-edited one) would
|
||
// drop them out of the world, so those still keep the configured spawn.
|
||
//
|
||
// ⛑⛑ 2026-09-27 修:判据原来是「存档 raid 必须等于配置主城」,于是只要上一次是离开主城
|
||
// 退出的(GM 面板切到 23300999 凌云/重霄最典型),这里恒不成立 ⇒ 人掉回配置出生点。
|
||
// 存档现在把 raid/instance/universe 一起带出来(见 PlayerProgressStore),
|
||
// 这里照原样进那张图 + 那个点。
|
||
var spawnPosition = Profile.WorldSpawn;
|
||
var spawnFacing = Profile.WorldFacing;
|
||
var spawnRaidId = Profile.RaidId;
|
||
var spawnInstanceId = Profile.SceneInstanceId;
|
||
var spawnUniverseId = Profile.UniverseId;
|
||
var saved = state.Progress;
|
||
if (saved.HasSavedPosition
|
||
&& saved.SavedRaidId != 0
|
||
&& !GameRouter.IsExtractionRaid(saved.SavedRaidId)
|
||
&& TryResolveSavedScene(saved.SavedRaidId, saved.SavedInstanceId, saved.SavedUniverseId,
|
||
out var savedInstanceId, out var savedUniverseId))
|
||
{
|
||
spawnPosition = new Vec3(saved.SavedPositionX, saved.SavedPositionY, saved.SavedPositionZ);
|
||
spawnFacing = saved.SavedFacing;
|
||
spawnRaidId = saved.SavedRaidId;
|
||
spawnInstanceId = savedInstanceId;
|
||
spawnUniverseId = savedUniverseId;
|
||
ctx.Session.Log.Info(
|
||
$"[WORLD-MIN] restored position raid={spawnRaidId} instance={spawnInstanceId} " +
|
||
$"universe={spawnUniverseId} " +
|
||
$"pos=({spawnPosition.X:0.##},{spawnPosition.Y:0.##},{spawnPosition.Z:0.##}) facing={spawnFacing:0.##}");
|
||
}
|
||
else if (saved.HasSavedPosition)
|
||
{
|
||
ctx.Session.Log.Warn(
|
||
$"[WORLD-MIN] saved position dropped (raid={saved.SavedRaidId} instance={saved.SavedInstanceId}) " +
|
||
$"— no scene to replay it in, falling back to the configured spawn");
|
||
}
|
||
|
||
// Keep the session's notion of "where am I" in step with the scene we actually entered,
|
||
// otherwise the next throttled position save would write the main-city raid next to a
|
||
// coordinate that belongs to another map.
|
||
state.ActiveRaidId = spawnRaidId;
|
||
state.ActiveInstanceId = spawnInstanceId;
|
||
state.ActiveUniverseId = spawnUniverseId;
|
||
state.ActiveContentScene = spawnRaidId == Profile.RaidId ? "WorldMap_Release" : $"raid-{spawnRaidId}";
|
||
|
||
state.WorldEntryCreateHeroPosition = spawnPosition;
|
||
state.WorldEntryCreateHeroFacing = spawnFacing;
|
||
|
||
// Minimal mode intentionally does not ask the client to play an authored opening Timeline.
|
||
// Scene assets and native quest/story systems remain entirely client-owned.
|
||
await ctx.NotifyAsync(MethodId.SyncEnterScene, RuntimePayloadFactory.EnterScene(
|
||
spawnRaidId,
|
||
spawnInstanceId,
|
||
spawnUniverseId,
|
||
Profile.InitialUnitId,
|
||
Profile.InitialSpiritTemplateId,
|
||
spawnPosition,
|
||
spawnFacing,
|
||
switchShowId: 0,
|
||
isSwitchSpiritShow: false));
|
||
|
||
ctx.Session.Log.Info($"[WORLD-MIN] enter generation={state.WorldEntryControlGeneration} raid={spawnRaidId} instance={spawnInstanceId} universe={spawnUniverseId} unit={Profile.InitialUnitId} template={Profile.InitialSpiritTemplateId} pos=({spawnPosition.X:0.##},{spawnPosition.Y:0.##},{spawnPosition.Z:0.##}) opening=false switchShow=0");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把存档里的 raid 补成一组能进的 (instance, universe)。
|
||
///
|
||
/// 2026-09-27:存档现在直接带 instance/universe;旧存档(06-27 之前写的)只有 raid,
|
||
/// 这里按「主城用配置、其余查场景 preset」补齐。查不到 ⇒ false ⇒ 调用方放弃恢复,
|
||
/// 走配置出生点 —— 宁可回出生点,也不要拿一个错误的 instance 进图。
|
||
/// </summary>
|
||
private static bool TryResolveSavedScene(
|
||
uint raidId, ulong instanceId, uint universeId, out ulong resolvedInstance, out uint resolvedUniverse)
|
||
{
|
||
if (instanceId != 0 && universeId != 0)
|
||
{
|
||
resolvedInstance = instanceId;
|
||
resolvedUniverse = universeId;
|
||
return true;
|
||
}
|
||
|
||
if (raidId == Profile.RaidId)
|
||
{
|
||
resolvedInstance = Profile.SceneInstanceId;
|
||
resolvedUniverse = Profile.UniverseId;
|
||
return true;
|
||
}
|
||
|
||
if (GameAdminBridge.TryGetScenePreset(raidId, out var preset) && preset is not null)
|
||
{
|
||
resolvedInstance = preset.InstanceId;
|
||
resolvedUniverse = preset.UniverseId != 0 ? preset.UniverseId : Profile.UniverseId;
|
||
return resolvedInstance != 0;
|
||
}
|
||
|
||
resolvedInstance = 0;
|
||
resolvedUniverse = 0;
|
||
return false;
|
||
}
|
||
|
||
|
||
async Task<bool> CommitWorldEntryCreateHeroData4229938(RpcContext ctx, int generation)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
ulong unitId;
|
||
uint templateId;
|
||
ulong playerPid;
|
||
Vec3 createHeroPosition;
|
||
float createHeroFacing;
|
||
string? reject = null;
|
||
|
||
// Port the proven v7 preflight literally: validate the complete canonical identity and
|
||
// CreateHero transform BEFORE claiming/sending the first authority packet. A failure after
|
||
// LogicAgentEnter would leave an orphan authority entity, so this block is fail-closed.
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized)
|
||
reject = "transaction-not-pending";
|
||
else if (generation <= 0 || generation != state.WorldEntryControlGeneration)
|
||
reject = "generation-mismatch";
|
||
else if (Profile.PlayerPid == 0 || state.WorldEntryControlUnit == 0 || state.WorldEntryControlTemplate == 0)
|
||
reject = "zero-identity";
|
||
else if (state.ActiveSpiritUnitId != state.WorldEntryControlUnit
|
||
|| state.ActiveSpiritTemplateId != state.WorldEntryControlTemplate)
|
||
reject = "active-identity-mismatch";
|
||
// Login must land on the configured initial actor. Scene switches re-arm the
|
||
// transaction on whichever character is currently active (flag set by rearm only).
|
||
else if (!state.WorldEntryAllowNonInitialControl
|
||
&& (state.WorldEntryControlUnit != Profile.InitialUnitId
|
||
|| state.WorldEntryControlTemplate != Profile.InitialSpiritTemplateId))
|
||
reject = "profile-identity-mismatch";
|
||
else if (!ClientConfigRepository.Characters().Any(x =>
|
||
x.UnitId == state.WorldEntryControlUnit &&
|
||
x.TemplateId == state.WorldEntryControlTemplate))
|
||
reject = "canonical-roster-row-missing";
|
||
else if (!float.IsFinite(state.WorldEntryCreateHeroPosition.X)
|
||
|| !float.IsFinite(state.WorldEntryCreateHeroPosition.Y)
|
||
|| !float.IsFinite(state.WorldEntryCreateHeroPosition.Z)
|
||
|| !float.IsFinite(state.WorldEntryCreateHeroFacing))
|
||
reject = "non-finite-createhero-transform";
|
||
else if (state.WorldEntryLogicProjectionPublishedGeneration == generation
|
||
&& state.WorldEntryCurrentMetadataPublishedGeneration == generation)
|
||
return true;
|
||
else if (state.WorldEntryLogicProjectionPublishedGeneration != 0
|
||
|| state.WorldEntryCurrentMetadataPublishedGeneration != 0)
|
||
reject = "projection-already-claimed";
|
||
|
||
unitId = state.WorldEntryControlUnit;
|
||
templateId = state.WorldEntryControlTemplate;
|
||
playerPid = Profile.PlayerPid;
|
||
createHeroPosition = state.WorldEntryCreateHeroPosition;
|
||
createHeroFacing = state.WorldEntryCreateHeroFacing;
|
||
|
||
if (reject is null)
|
||
{
|
||
// Both generation edges are claimed before any send. Partial failure remains
|
||
// fail-closed; a duplicate callback must never replay half of the transaction.
|
||
state.WorldEntryLogicProjectionPublishedGeneration = -generation;
|
||
state.WorldEntryCurrentMetadataPublishedGeneration = -generation;
|
||
}
|
||
}
|
||
|
||
if (reject is not null)
|
||
{
|
||
ctx.Session.Log.Warn($"[WORLD-V7] commit rejected generation={generation} reason={reject}");
|
||
return false;
|
||
}
|
||
|
||
// Exact proven order from the supplied v7 guide. No sleep, no presentation hydration,
|
||
// no buffs and no weapon/fashion deltas may be interleaved into this quartet.
|
||
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(unitId));
|
||
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent, WorldCodec.ManagedLogicAgent(unitId, playerPid, 0));
|
||
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
|
||
RuntimePayloadFactory.ExistingUnitProjection(unitId, templateId, createHeroPosition, createHeroFacing, state));
|
||
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (state.WorldEntryLogicProjectionPublishedGeneration == -generation)
|
||
state.WorldEntryLogicProjectionPublishedGeneration = generation;
|
||
}
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
|
||
WorldCodec.CurrentSpirit(playerPid, templateId, unitId, isAgentSwitch: false));
|
||
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (state.WorldEntryCurrentMetadataPublishedGeneration == -generation)
|
||
state.WorldEntryCurrentMetadataPublishedGeneration = generation;
|
||
state.CurrentSpiritStateSent = true;
|
||
state.ControlPublished = true;
|
||
}
|
||
|
||
ctx.Session.Log.Info($"[HANDOFF-V7] generation={generation} exact=LogicAgentEnter->ManagedLogicAgent->sameID-AOI->CurrentSpirit unit={unitId} template={templateId} presentation=false buffs=false duplicateActor=false");
|
||
return true;
|
||
}
|
||
|
||
|
||
async Task OnLoadSceneCompleted(RpcContext ctx, ulong sceneId, ulong sessionId)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
int generation = 0;
|
||
string? reject = null;
|
||
bool stale = false;
|
||
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (sceneId == 0 || sessionId == 0)
|
||
reject = "invalid-scene-session";
|
||
else if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized)
|
||
reject = "transaction-not-pending";
|
||
else if (state.WorldEntrySceneId4229938 == 0 && state.WorldEntrySessionId4229938 == 0)
|
||
{
|
||
// ★ R101:切图后「上一张图迟到的 ack」往往比新图的先到。它一旦占了权威位,
|
||
// 新图的 ack 就会被判 correlation-mismatch 永久拒绝 ⇒ world-entry 永不
|
||
// finalize ⇒ EnsureGadgetGrid 不跑 ⇒ 摊位/器材/补货/派对家具全都不摆。
|
||
// 有 expected 时只认它:不符的静默丢弃,既不占权威位也不报 mismatch。
|
||
if (state.WorldEntryExpectedSceneId != 0 && sceneId != state.WorldEntryExpectedSceneId)
|
||
stale = true;
|
||
else
|
||
{
|
||
state.WorldEntrySceneId4229938 = sceneId;
|
||
state.WorldEntrySessionId4229938 = sessionId;
|
||
}
|
||
}
|
||
else if (state.WorldEntrySceneId4229938 != sceneId || state.WorldEntrySessionId4229938 != sessionId)
|
||
reject = "correlation-mismatch";
|
||
|
||
if (reject is null && !stale)
|
||
{
|
||
generation = state.WorldEntryControlGeneration;
|
||
state.SceneLoadAckSeen = true;
|
||
state.SceneId = sceneId;
|
||
}
|
||
}
|
||
|
||
if (stale)
|
||
{
|
||
ctx.Session.Log.Info($"[WORLD] ignoring stale load-ack scene={sceneId} session={sessionId} (expecting {state.WorldEntryExpectedSceneId})");
|
||
return;
|
||
}
|
||
|
||
if (reject is not null)
|
||
{
|
||
ctx.Session.Log.Warn($"[WORLD] AskLoadSceneCompleted rejected scene={sceneId} session={sessionId} reason={reject}");
|
||
return;
|
||
}
|
||
|
||
if (!await CommitWorldEntryCreateHeroData4229938(ctx, generation))
|
||
return;
|
||
|
||
// The confirmed handoff transaction ends with CurrentSpirit. Do not interleave roster/combat/state
|
||
// hydration here; the client must reach AskLoadingFinished with the post-load quartet intact.
|
||
ctx.Session.Log.Info($"[WORLD] load-edge committed generation={generation} scene={sceneId} session={sessionId} logicProjection={state.WorldEntryLogicProjectionPublishedGeneration} currentMetadata={state.WorldEntryCurrentMetadataPublishedGeneration} sceneComplete=false hydration=false");
|
||
}
|
||
|
||
Task OnLoadGameResourcesCompleted(RpcContext ctx, ulong sceneId)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
state.GameResourcesReadySeen = true;
|
||
if (state.SceneId == 0 && sceneId != 0)
|
||
state.SceneId = sceneId;
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
async Task OnLoadingFinished(RpcContext ctx, ulong sceneId, ulong sessionId)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
int generation = 0;
|
||
string? reject = null;
|
||
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (sceneId == 0 || sessionId == 0)
|
||
reject = "invalid-scene-session";
|
||
else if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized)
|
||
reject = state.WorldEntryControlFinalized ? "already-finalized" : "transaction-not-pending";
|
||
else if (sceneId != state.WorldEntrySceneId4229938 || sessionId != state.WorldEntrySessionId4229938)
|
||
reject = "correlation-mismatch";
|
||
else
|
||
{
|
||
generation = state.WorldEntryControlGeneration;
|
||
if (state.WorldEntryLogicProjectionPublishedGeneration != generation
|
||
|| state.WorldEntryCurrentMetadataPublishedGeneration != generation)
|
||
reject = "v7-handoff-incomplete";
|
||
else if (state.WorldEntryLoadingCompletedGeneration == generation)
|
||
return;
|
||
else if (state.WorldEntryLoadingCompletedGeneration != 0)
|
||
reject = "loading-edge-already-claimed";
|
||
else if (state.WorldEntryControlFinalizingGeneration != 0)
|
||
reject = "finalizer-already-claimed";
|
||
else
|
||
{
|
||
state.WorldEntryLoadingCompletedGeneration = -generation;
|
||
state.WorldEntryControlFinalizingGeneration = -generation;
|
||
state.SceneId = sceneId;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (reject is not null)
|
||
{
|
||
ctx.Session.Log.Warn($"[WORLD-V7] AskLoadingFinished rejected scene={sceneId} session={sessionId} generation={generation} reason={reject}");
|
||
return;
|
||
}
|
||
|
||
// Supplied guide invariant: SyncSceneLoadCompleted is the FIRST and ONLY S2C gameplay edge
|
||
// of AskLoadingFinished. Do not put buffs/weapon/fashion/current before it. All optional
|
||
// runtime hydration is deferred to the first real gameplay movement after Ready=true.
|
||
await ctx.NotifyAsync(MethodId.SyncSceneLoadCompleted, WorldCodec.SceneLoadCompleted(sceneId));
|
||
|
||
lock (state.SyncRoot)
|
||
{
|
||
state.SceneCompletionSent = true;
|
||
if (state.WorldEntryLoadingCompletedGeneration == -generation)
|
||
state.WorldEntryLoadingCompletedGeneration = generation;
|
||
state.LivePlayerProfilePublished = false;
|
||
state.CombatProfilePublished = false;
|
||
state.AccountArmoryPublished = true; // compact referenced subset already arrived in SyncPlayerInfo
|
||
state.FreeRoamReleased = false;
|
||
state.MovementCapabilityPublished = false;
|
||
state.AllBuildBuffsPublished = false;
|
||
state.InitialActorPresentationPublished = false;
|
||
state.InitialCapabilityBuffsPublished = false;
|
||
state.WorldEntryControlPending = false;
|
||
state.WorldEntryControlFinalized = true;
|
||
if (state.WorldEntryControlFinalizingGeneration == -generation)
|
||
state.WorldEntryControlFinalizingGeneration = generation;
|
||
state.Ready = true;
|
||
state.WorldEntryIsAirportTravel = false;
|
||
}
|
||
|
||
// Normal post-load finalization, deliberately after the terminal scene-ready edge.
|
||
// Reset S011 story + ownership one-shot flags so the client's freshly-built story
|
||
// instance receives the root bootstrap and vehicle list after every scene load.
|
||
var vehicleRuntime = GetVehicleRuntimeState(ctx.Session);
|
||
lock (vehicleRuntime.SyncRoot)
|
||
{
|
||
vehicleRuntime.StoryRootSent = false;
|
||
vehicleRuntime.StorySeenAnyRpc = false;
|
||
vehicleRuntime.StoryHighestConfirmedRpcId = -1;
|
||
vehicleRuntime.StoryPendingRpcIds.Clear();
|
||
vehicleRuntime.StoryCommittedRpcIds.Clear();
|
||
vehicleRuntime.OwnershipSent = false;
|
||
vehicleRuntime.AetherInitSent = false;
|
||
}
|
||
ResetAllWeaponsSentFlag(ctx.Session);
|
||
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
|
||
await PublishVehicleOwnership(ctx);
|
||
await PublishAllAccountWeaponsIfEnabled(ctx);
|
||
await EnsureAetherVehicleInit(ctx);
|
||
// 必须在 EnsureAetherVehicleInit 之后:没有 HasZoneGraph=true 客户端根本不建人群层。
|
||
await PublishAmbientCrowdAsync(ctx);
|
||
// 潜水解锁:服务端没有能开 DiveManager 门的专用 S2C,只能走官方远程 GM 反射通道。
|
||
await PublishDivingUnlockAsync(ctx);
|
||
|
||
// 自动氛围行人:起一个 per-session 维护循环,按真实行人路网在玩家周围铺人并发游荡意图。
|
||
// 行人必须先等玩家上报坐标(循环内部会等 HasLastReportedPlayerTransform),所以这里只负责启动。
|
||
EnsureAmbientPeds(ctx.Session);
|
||
// 自动功能 NPC:跟随循环,玩家走近哪家店就把那家的店员放出来(配置 Aether.FunctionalNpcsEnabled)。
|
||
EnsureFunctionalNpcs(ctx.Session);
|
||
// 可玩角色常驻:按各自日程站在城里(走近才放、走远撤掉、回来再放)—— 见 GameRouter.RosterNpc.cs。
|
||
EnsureRosterNpcs(ctx.Session);
|
||
// Plate(盘)演出管线试点:配置 gameplay.plate.pushOnWorldReady=true 才推一次
|
||
// (默认关 —— 先手动 POST /api/plate/push 试线格式)。见 GameRouter.Plate.cs。
|
||
await EnsurePlateAsync(ctx.Session);
|
||
// 自动地盘敌人:跟随循环,玩家走近哪个帮派地盘就把那帮人放出来
|
||
// (配置 Aether.EnemyTerritoryEnabled;坐标来自 FactionAreaPoint × FactionInfluenceArea)。
|
||
EnsureEnemyTerritory(ctx.Session);
|
||
// T3-7 真实车流:同样要等玩家上报坐标,循环内部会等 HasLastReportedPlayerTransform。
|
||
// ★ await:起循环前要先清掉上一个会话残留的车(切场景/切角色后上一批车会冻在原地)。
|
||
await EnsureTrafficAsync(ctx.Session);
|
||
// 世界互动物件(Gadget):按 40m 网格 + sector 流式下发,客户端从自己的 GadgetBlob 出货。
|
||
// 这就是 AnantaGadgetLoader 那个 exe 干的事,现在由服务端直接推 —— 不再需要额外注入器。
|
||
EnsureGadgetGrid(ctx.Session);
|
||
// 场景物品(可破坏 / 可投掷物 / 路灯 / 街边道具):同一套 40m 网格流,走
|
||
// SyncDestructibleGridAOIDecrease(68969593)。这就是 AnantaWorldMod 注入器干的事。
|
||
EnsureSceneItemGrid(ctx.Session);
|
||
// 搜打撤:只有当前 raid 在名单里才补(里面自带 IsExtractionRaid 守卫)。
|
||
// SyncPlayerEnterExtractionShooter 是空体包 —— 客户端靠它把 HUD 切成副本态。
|
||
await PushExtractionEnterAsync(ctx.Session, state);
|
||
await EnsureVehicleStoryRoot(ctx);
|
||
await PushSessionTimeAsync(ctx.Session);
|
||
await PushSessionWeatherAsync(ctx.Session);
|
||
await PublishMapFogUnlockAsync(ctx);
|
||
// 大地图入口(传送点 / 地铁站图标 + 点击真传送)—— 见 GameRouter.MapEntrance.cs。
|
||
// 放在迷雾之后:两者都是「一次性世界级推送」,且入口帧量更大(93 × 3),
|
||
// 内部自己走后台分批,不会堵住这里。
|
||
await PublishMapEntrancesAsync(ctx);
|
||
await PublishPlayerTasksAsync(ctx);
|
||
await PublishSwitchRoleTeamAsync(ctx);
|
||
await PublishSceneLandingAsync(ctx);
|
||
|
||
ctx.Session.Log.Info($"[WORLD-V7] ready generation={generation} scene={sceneId} session={sessionId} exactGuide=true sceneComplete=first actorPresentation=client-owned buffs=deferred-first-movement noStory=true noAOI=true");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 潜水解锁(<c>gameplay.divingMode == "on"</c>)—— 让所有角色能下潜,而不只是水面游泳。
|
||
///
|
||
/// 只负责<b>门控</b>那一半:通过 <c>SyncRunGmUtils</c>(66042045) 让客户端反射执行
|
||
/// <c>LX6.Utils.GmUtils</c> 的潜水开关(默认 <c>EnableDive</c>,可配 <c>ForceDive</c> /
|
||
/// <c>SetUnderWaterFilter</c>)。客户端实现是 <c>GameGMToClientImpl.SyncRunGmUtils</c>
|
||
/// (RVA 0x12752F10,真实现),按 methodName 反射解析,执行全程在客户端进程内。
|
||
/// 服务端<b>没有</b>专用 S2C 能代替它 —— <c>DiveManager.CheckIsEnableDiving()</c> 读的是
|
||
/// 客户端静态字段 <c>enableInGamePlay</c>。
|
||
///
|
||
/// <b>氧气</b>那一半不在这里:由 <c>CharacterCapabilities.GlobalCapabilityBuffIds</c> 挂的
|
||
/// 52606233「潜水服buff」(不会掉氧气且更快恢复)随角色 buff 快照下发,切角色会自动重发。
|
||
/// </summary>
|
||
async Task PublishDivingUnlockAsync(RpcContext ctx)
|
||
{
|
||
var gameplay = PrivateServerConfigStore.Current.Gameplay;
|
||
if (!string.Equals(gameplay.DivingMode, "on", StringComparison.OrdinalIgnoreCase))
|
||
return;
|
||
|
||
await PublishGmCommandsAsync(ctx, "DIVING", gameplay.DivingGmMethods, gameplay.DivingGmArgs);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 官方远程 GM 反射通道的公共推送口 —— <c>SyncRunGmUtils</c>(66042045)。
|
||
/// 客户端 <c>GameGMToClientImpl</c>(RVA 0x12752F10 真实现)按 methodName 反射调用
|
||
/// <c>LX6.Utils.GmUtils</c> 的静态方法,执行全程在客户端进程内,服务端只发一次 Notify。
|
||
/// </summary>
|
||
async Task PublishGmCommandsAsync(RpcContext ctx, string tag, string[]? methods, string[]? args)
|
||
{
|
||
var list = (methods ?? [])
|
||
.Where(m => !string.IsNullOrWhiteSpace(m))
|
||
.Select(m => m.Trim())
|
||
.ToArray();
|
||
if (list.Length == 0)
|
||
return;
|
||
|
||
// ⛑ 参数必须小写 "true":客户端 GmUtils.RunGmUtilsCommand 用 Newtonsoft.Json 逐个解析
|
||
// 参数字符串(实测 2026-09-22 21:52:09 传 "True" ⇒ JsonReaderException: Unexpected
|
||
// character encountered while parsing value: T ⇒ 整条 GM 指令被丢弃,潜水没开)。
|
||
var a = (args is { Length: > 0 } x ? x : ["true"]).ToArray();
|
||
|
||
foreach (var method in list)
|
||
{
|
||
var dto = new SceneMethods.SyncRunGmUtils7 { methodName = method, args = a.ToList() };
|
||
await ctx.NotifyAsync(MethodId.SyncRunGmUtils, UxSerializer.Serialize(dto));
|
||
ctx.Session.Log.Info($"[{tag}] gm method={method} args=[{string.Join(",", a)}] mid=66042045");
|
||
}
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// Re-apply the landing pose for a scene switch into the raid/instance the client was already
|
||
/// running.
|
||
///
|
||
/// When both ends of a switch are the same raid+instance the client reloads the level but keeps
|
||
/// the avatar at its current coordinates — SyncEnterScene's position only seeds a hero spawn,
|
||
/// and no spawn happens because the level is already there. Sending SetPositionType.Teleport
|
||
/// after the entry flow reached ready is what actually moves the player, and doing it here
|
||
/// (rather than next to SyncEnterScene) guarantees the freshly respawned unit already exists.
|
||
/// </summary>
|
||
async Task PublishSceneLandingAsync(RpcContext ctx)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
Vec3 position;
|
||
float facing;
|
||
ulong unitId;
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (state.SceneLandingPosition is not { } pending)
|
||
return;
|
||
position = pending;
|
||
facing = state.SceneLandingFacing ?? 0f;
|
||
state.SceneLandingPosition = null;
|
||
state.SceneLandingFacing = null;
|
||
unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
|
||
}
|
||
|
||
// Give the client a beat to hand control back after SyncGamePause(false) before forcing
|
||
// the transform, otherwise the free-roam restore can win the race.
|
||
await Task.Delay(300);
|
||
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
|
||
WorldCodec.PositionAndFacing(unitId, position, facing));
|
||
ctx.Session.Log.Info($"[SCENE] landing corrected unit={unitId} pos=({position.X:F2},{position.Y:F2},{position.Z:F2}) facing={facing:F2}");
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// Reveal the whole world map by unlocking fog on every scene the config knows about.
|
||
///
|
||
/// The login payload field PlayerClientInfoAchievement.SceneFogMaps is a dead end: no client
|
||
/// code reads it for fog state (MapSystem_FogMap asks MapFogDataMgr, which is fed exclusively
|
||
/// by the S2C below). Verified in-game — 212 fog entries at login changed nothing.
|
||
///
|
||
/// The S2C's first argument is a **sceneId** (RaidConfig.SceneId, e.g. 20001222), not a raid
|
||
/// id: MapFogDataMgr.IsInFog(sceneId, x, z) is the only lookup the fog view performs.
|
||
/// </summary>
|
||
async Task PublishMapFogUnlockAsync(RpcContext ctx)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (state.MapFogUnlockPublished)
|
||
return;
|
||
state.MapFogUnlockPublished = true;
|
||
}
|
||
|
||
var sceneIds = new List<uint>(SceneCatalog4229938.SceneIds);
|
||
if (Profile.SceneInstanceId != 0 && Profile.SceneInstanceId <= uint.MaxValue)
|
||
{
|
||
var current = (uint)Profile.SceneInstanceId;
|
||
if (!sceneIds.Contains(current))
|
||
sceneIds.Add(current);
|
||
}
|
||
|
||
if (sceneIds.Count == 0)
|
||
{
|
||
ctx.Session.Log.Info("[MAP-FOG] no scene ids in RaidConfig; fog left to the client");
|
||
return;
|
||
}
|
||
|
||
// ⛑️ 这曾经是 handler 里 **260~316 个同步 NotifyAsync**(packets 日志:进世界那一秒
|
||
// 489 个 S2C 里有 260 个就是它)。收包循环是串行的(TcpServer.HandleClientAsync:
|
||
// 读完一帧 → await DispatchAsync → 才读下一帧),handler 不返回就一帧都不读,
|
||
// 客户端此时发来的 RPC 全部排队 —— 与 2026-09-23 那次 671 把武器导致的断线风暴
|
||
// 是同一个死法,只是量级小一些。所以挪到后台摊平发,handler 立刻返回。
|
||
var session = ctx.Session;
|
||
var ids = sceneIds;
|
||
_ = Task.Run(async () =>
|
||
{
|
||
const int batchSize = 25;
|
||
const int batchDelayMs = 10;
|
||
var sent = 0;
|
||
for (var i = 0; i < ids.Count; i++)
|
||
{
|
||
try
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncSceneFogMapAllUnlock,
|
||
UxSerializer.Serialize(new GameMethods.SyncSceneFogMapAllUnlockData4229938
|
||
{
|
||
SceneId = ids[i],
|
||
Unlocked = true,
|
||
}), CancellationToken.None);
|
||
sent++;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[MAP-FOG] unlock failed scene={ids[i]}: {ex.Message}");
|
||
break;
|
||
}
|
||
if ((i + 1) % batchSize == 0 && i + 1 < ids.Count)
|
||
await Task.Delay(batchDelayMs, CancellationToken.None);
|
||
}
|
||
session.Log.Info($"[MAP-FOG] SyncSceneFogMapAllUnlock scenes={sent}/{ids.Count} first={ids[0]} current={Profile.SceneInstanceId}");
|
||
});
|
||
|
||
ctx.Session.Log.Info($"[MAP-FOG] fog unlock scheduled in the background scenes={sceneIds.Count}");
|
||
}
|
||
|
||
async Task OnLoadedInSameScene(RpcContext ctx)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
bool firstPresentationEdge;
|
||
lock (state.SyncRoot)
|
||
{
|
||
firstPresentationEdge = !state.SameSceneAckSeen;
|
||
state.SameSceneAckSeen = true;
|
||
if (firstPresentationEdge && Profile.WorldEntryOpeningEnabled && state.WorldEntryControlGeneration > 0)
|
||
state.WorldEntryOpeningEndedGeneration = state.WorldEntryControlGeneration;
|
||
}
|
||
|
||
// 4229938 emits AskLoadedInSameScene after every authored same-scene character switch, not
|
||
// only once after login. SyncPlayerLoadRate is therefore an acknowledgement for EACH edge.
|
||
// Keep the opening-generation bookkeeping one-shot, but never suppress the per-switch ack.
|
||
await ctx.NotifyAsync(MethodId.SyncPlayerLoadRate, WorldCodec.PlayerLoadRate());
|
||
ctx.Session.Log.Info($"[SAME-SCENE] load-rate ack=true first={firstPresentationEdge} generation={state.WorldEntryControlGeneration} pendingSwitch={state.PendingSwitchTemplateId}/{state.PendingSwitchUnitId} controlMutation=false");
|
||
}
|
||
}
|