feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio

This commit is contained in:
CAtlas0784 committed 2026-10-29 02:44:24 +07:00
1 parent 74e2963a12
commit 3e5adf1dc5
1571 files changed
+336468 -6348

No files matched your search

@@ -1,8 +1,8 @@
using Ananta.SDK.Network;
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;
@@ -20,7 +20,9 @@ internal sealed partial class GameRouter
return existing;
var created = new WorldEntryState();
created.SeedHistoryFromGlobal();
// 改装件读盘(开关 repairShop.persistTuning,默认关)。只在 state 首次创建时读一次,
// 之后由 AskBuyModifyParts 直接写内存并在每次购买后落盘。
VehicleTuningStore.Load(Profile.PlayerPid, created);
ctx.Session.Items[WorldStateKey] = created;
return created;
}
@@ -45,6 +47,8 @@ internal sealed partial class GameRouter
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;
@@ -52,11 +56,14 @@ internal sealed partial class GameRouter
state.HasLastReportedPlayerTransform = false;
state.LastSwitchShowId = 0;
state.AllBuildBuffsPublished = false;
state.GaragePublished = false;
state.AetherVehicleInitSent = 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;
}
}
@@ -71,11 +78,36 @@ internal sealed partial class GameRouter
}
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());
await ctx.NotifyAsync(MethodId.SyncPlayerInfo, RuntimePayloadFactory.MinimalPlayerInfo4229938());
// Phone contacts book per character (summon numbers + real city NPC contacts) —
// the NETWORK OF CONTACTS app renders exactly this push.
await PushSpiritHandbookAsync(ctx.Session);
// ⛑ 搜打撤的背包必须在<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);
}
@@ -90,38 +122,104 @@ internal sealed partial class GameRouter
ArmWorldEntry4229938(state);
state.WorldEntrySwitchShowId = 0;
var rollback = WorldEntryState.TryGetGlobalCheckpointSecondsAgo(10.0);
Vec3 spawnPos;
float spawnFacing;
if (rollback is not null && (DateTime.UtcNow - rollback.Timestamp).TotalSeconds < 60)
// 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))
{
spawnPos = rollback.Position;
spawnFacing = rollback.Facing;
ctx.Session.Log.Info($"[RECONNECT-ROLLBACK] Reconnecting player restored to 10s prior position ({spawnPos.X:F1},{spawnPos.Y:F1},{spawnPos.Z:F1}) facing={spawnFacing:F1}");
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
else if (saved.HasSavedPosition)
{
spawnPos = Profile.WorldSpawn;
spawnFacing = Profile.WorldFacing;
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");
}
state.WorldEntryCreateHeroPosition = spawnPos;
// 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(
Profile.RaidId,
Profile.SceneInstanceId,
Profile.UniverseId,
spawnRaidId,
spawnInstanceId,
spawnUniverseId,
Profile.InitialUnitId,
Profile.InitialSpiritTemplateId,
spawnPos,
spawnPosition,
spawnFacing,
switchShowId: 0,
isSwitchSpiritShow: false));
ctx.Session.Log.Info($"[WORLD-MIN] enter generation={state.WorldEntryControlGeneration} raid={Profile.RaidId} instance={Profile.SceneInstanceId} universe={Profile.UniverseId} unit={Profile.InitialUnitId} template={Profile.InitialSpiritTemplateId} pos=({spawnPos.X:0.##},{spawnPos.Y:0.##},{spawnPos.Z:0.##}) opening=false switchShow=0");
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;
}
@@ -149,8 +247,11 @@ internal sealed partial class GameRouter
else if (state.ActiveSpiritUnitId != state.WorldEntryControlUnit
|| state.ActiveSpiritTemplateId != state.WorldEntryControlTemplate)
reject = "active-identity-mismatch";
else if (state.WorldEntryControlUnit != Profile.InitialUnitId
|| state.WorldEntryControlTemplate != Profile.InitialSpiritTemplateId)
// 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 &&
@@ -194,7 +295,7 @@ internal sealed partial class GameRouter
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));
RuntimePayloadFactory.ExistingUnitProjection(unitId, templateId, createHeroPosition, createHeroFacing, state));
lock (state.SyncRoot)
{
@@ -223,6 +324,7 @@ internal sealed partial class GameRouter
var state = GetWorldState(ctx);
int generation = 0;
string? reject = null;
bool stale = false;
lock (state.SyncRoot)
{
@@ -232,13 +334,22 @@ internal sealed partial class GameRouter
reject = "transaction-not-pending";
else if (state.WorldEntrySceneId4229938 == 0 && state.WorldEntrySessionId4229938 == 0)
{
state.WorldEntrySceneId4229938 = sceneId;
state.WorldEntrySessionId4229938 = sessionId;
// ★ 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)
if (reject is null && !stale)
{
generation = state.WorldEntryControlGeneration;
state.SceneLoadAckSeen = true;
@@ -246,6 +357,12 @@ internal sealed partial class GameRouter
}
}
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}");
@@ -326,9 +443,6 @@ internal sealed partial class GameRouter
state.FreeRoamReleased = false;
state.MovementCapabilityPublished = false;
state.AllBuildBuffsPublished = false;
state.GaragePublished = false;
state.AetherVehicleInitSent = false;
ResetVehicleStory(ctx.Session);
state.InitialActorPresentationPublished = false;
state.InitialCapabilityBuffsPublished = false;
state.WorldEntryControlPending = false;
@@ -340,99 +454,226 @@ internal sealed partial class GameRouter
}
// 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 PublishGarageAsync(ctx);
await PublishAetherInitAsync(ctx);
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);
var activitiesSync = new GameMethods.SyncAllActivities
{
activities =
[
new GameMethods.AwardActivityItem
{
BaseActivityInfo = new GameMethods.BaseActivityInfo { CfgId = 2684, StartTime = 0, EndTime = 2000000000 },
ActivityData = new GameMethods.ActivityData { CfgId = 2684, ShowRedPoint = true, IsOutOfDate = false }
},
new GameMethods.AwardActivityItem
{
BaseActivityInfo = new GameMethods.BaseActivityInfo { CfgId = 2685, StartTime = 0, EndTime = 2000000000 },
ActivityData = new GameMethods.ActivityData { CfgId = 2685, ShowRedPoint = true, IsOutOfDate = false }
},
new GameMethods.AwardActivityItem
{
BaseActivityInfo = new GameMethods.BaseActivityInfo { CfgId = 2686, StartTime = 0, EndTime = 2000000000 },
ActivityData = new GameMethods.ActivityData { CfgId = 2686, ShowRedPoint = true, IsOutOfDate = false }
}
]
};
await ctx.NotifyAsync(MethodId.SyncAllActivities, activitiesSync);
// Unlock all scene fog of war on the client across all city/world scenes
foreach (var fogSceneId in new uint[] { 1, 101, 102, 103, 1001, 10001, 20001125, 20001222, 20001223, 23300888, 23300999, (uint)state.SceneId, state.ActiveRaidId })
{
if (fogSceneId == 0) continue;
await ctx.NotifyAsync(MethodId.SyncSceneFogMapAllUnlock, new SceneMethods.SyncSceneFogMapAllUnlockInfo
{
SceneId = fogSceneId,
Unlocked = true
});
}
// Initialize story task tracking — the INTRO cutscene (train / name entry / gender
// select) is driven by the story root task 60004938 (PrivateServer1-proven): the
// client plays the opening when the current task is the story root.
const uint StoryRootTaskId = 60004938u;
await ctx.NotifyAsync(MethodId.SyncPlayerSingleTask, new SceneMethods.SyncPlayerSingleTask4229938
{
TaskId = StoryRootTaskId,
View = new SceneMethods.TaskViewData4229938
{
TaskId = StoryRootTaskId,
State = 1, // Accepted
},
State = new SceneMethods.TaskStateData4229938 { State = 1, Reason = 0 },
});
await ctx.NotifyAsync(MethodId.SyncCurrentTask, new SceneMethods.SyncCurrentTaskInfo
{
Type = 1,
TaskId = StoryRootTaskId,
EventId = 0,
FirstTime = true,
Reason = 0
});
// Establish the quest system with the current task and an empty furniture inventory
// (houses render clean instead of blocking on furniture state).
if (ctx.Session.Items.TryGetValue("taskStates", out var taskRaw) && taskRaw is Dictionary<uint, QuestEntry> taskStates)
{
foreach (var (taskId, entry) in taskStates.Where(kv => kv.Value.State == QuestStateAccepted))
{
await ctx.NotifyAsync(MethodId.SyncPlayerSingleTask, new SceneMethods.SyncPlayerSingleTask4229938
{
TaskId = taskId,
View = new SceneMethods.TaskViewData4229938
{
TaskId = taskId,
CounterValues = entry.Counters.ToList(),
State = entry.State,
},
State = new SceneMethods.TaskStateData4229938 { State = entry.State, Reason = 0 },
});
}
}
await ctx.NotifyAsync(MethodId.SyncPlayerAllTask, new SceneMethods.SyncPlayerAllTask4229938
{
CurrentTask = StoryRootTaskId,
LoginGameServer = true,
});
await ctx.NotifyAsync(MethodId.SyncHouseFurnitureInfo, new SceneMethods.SyncHouseFurnitureInfo4229938());
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=false noAOI=true");
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)
{
@@ -452,109 +693,4 @@ internal sealed partial class GameRouter
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");
}
internal static async Task<(bool Ok, string Message)> SwitchSceneDirectAsync(
TcpSession session,
uint raidId,
ulong instanceId,
uint universeId,
Vec3 position,
float facing,
bool isAirport = false)
{
if (!session.Items.TryGetValue(WorldStateKey, out var raw) || raw is not WorldEntryState state)
return (false, "no active world state for session");
int generation;
lock (state.SyncRoot)
{
var next = state.WorldEntryControlGeneration + 1;
if (next <= 0) next = 1;
generation = next;
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;
state.WorldEntryCreateHeroPosition = position;
state.WorldEntryCreateHeroFacing = facing;
state.LastReportedPlayerPosition = position;
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
state.HasLastReportedPlayerTransform = false;
state.LastSwitchShowId = 0;
state.AllBuildBuffsPublished = false;
state.GaragePublished = false;
state.AetherVehicleInitSent = false;
state.InitialActorPresentationPublished = false;
state.ActiveSpiritUnitId = Profile.InitialUnitId;
state.ActiveSpiritTemplateId = Profile.InitialSpiritTemplateId;
state.WorldEntryIsAirportTravel = isAirport;
state.Ready = false;
state.ActiveRaidId = raidId;
state.ActiveInstanceId = instanceId;
state.ActiveUniverseId = universeId;
}
var payload = RuntimePayloadFactory.EnterScene(
raidId,
instanceId,
universeId,
Profile.InitialUnitId,
Profile.InitialSpiritTemplateId,
position,
facing,
switchShowId: 0,
isSwitchSpiritShow: false);
var bytes = UxSerializer.Serialize(payload);
await session.NotifyAsync(MethodId.SyncEnterScene, bytes, CancellationToken.None);
session.Log.Info($"[WORLD] scene-switch generation={generation} raid={raidId} instance={instanceId} universe={universeId} pos=({position.X:0.##},{position.Y:0.##},{position.Z:0.##}) facing={facing:0.##} airport={isAirport}");
return (true, $"switched to raid={raidId} instance={instanceId} universe={universeId}");
}
internal static async Task HandleAirportOrTeleportAsync(TcpSession session)
{
if (!session.Items.TryGetValue(WorldStateKey, out var raw) || raw is not WorldEntryState state)
return;
uint currentRaid;
lock (state.SyncRoot)
{
currentRaid = state.ActiveRaidId;
}
if (currentRaid == 23300888)
{
session.Log.Info("[AIRPORT] travel: Main City (23300888) -> Longqi Village (23300999)");
await SwitchSceneDirectAsync(
session,
raidId: 23300999,
instanceId: 20001223,
universeId: 76000888,
position: new Vec3(-4719.8f, 168.5f, -2844.7f),
facing: 0f,
isAirport: true);
}
else
{
session.Log.Info($"[AIRPORT] travel: raid {currentRaid} -> Main City (23300888)");
await SwitchSceneDirectAsync(
session,
raidId: 23300888,
instanceId: 20001222,
universeId: 76000888,
position: new Vec3(-4468.3f, -6.15f, -2597.5f),
facing: 85.5f,
isAirport: true);
}
}
}