Files

697 lines
36 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}