using System.Runtime.CompilerServices; using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.Gameplay; using Ananta.Server.Protocol.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 白靖(Bai Jing,spirit 15021039)的原生能力 —— 斯安威斯坦(Sandevistan / 子弹时间)。 /// /// 取证(2026-09-26 03:1x 只读调研 + 2026-09-27 复核捐赠端 7k-v0.5 的同名实现): /// · BuffConfig 52959495「可以激活斯安威斯坦」Duration -1、IsHidden、Description /// 「职业标记buff」⇒ 这是能力门闸,由 CharacterCapabilities 作为常驻 buff 下发给白靖。 /// · BuffConfig 52959496「斯安威斯坦」Duration 5 s、Description「子弹时间」、 /// EffectIds [53500525]、IsClientDecide=true ⇒ 真正生效那一份,5 秒到期自己掉。 /// · SkillResourcesConfig Id=4「赛车手职业时缓次数」Max 1 / Default 1 / Interval 30 /// ⇒ 原生就是「一点能量、30 秒回满」,本文件的 5 s / 30 s 与之对齐,不是自造数值。 /// · 白靖的原生职业 = 赛车手(class 11300002 / job 4 Racer)。 /// /// 触发:只剩原生那一条(2026-09-27 用户拍板)。 /// 客户端按下职业技能按钮 ⇒ AskAddClientBuff(unitId, 52959496) ⇒ 本文件 /// TryStartBaiJingTime4229938;客户端收起(或 buff 自然到期后再次上报摘除)⇒ /// AskRemoveClientBuff(52959496) ⇒ StopBaiJingTime4229938。 /// ⛔ 移植时刻意删掉了捐赠端的第二条触发(挂到当前流派 ActiveSkill 上、reason="active-skill"): /// 那会让玩家每放一次 E 顺带进一次子弹时间,与原生语义不符 —— 原生是职业技能,不是战技。 /// 按钮到底怎么出来的(2026-09-27 二次取证,推翻上一次的判断): /// · 真正管用的是驾驶 HUD:DriveControlDriverStore.lua /// InitButtonValidCheck ⇒ BTN_VALID.PROFESSION_SKILL = self:ShowProfessionSkillButton() /// 而 ShowProfessionSkillButton 只有一行: /// return PlayerUnit and gBuffUtils.HasBuff(Pid, BuffConfig.TimeScaleBadgeBuff) /// ⇒ 骑上载具后按钮出不出来,只取决于玩家身上有没有那个门闸 buff,不查职业。 /// · 按下 ⇒ BattleMgr.lua:580 ClickProfessionSkill() ⇒ /// AskAddClientBuff(Pid, 52959496)(id 在 lua 里是硬编码的,跟我们的常量一致)。 /// · 服务端回 SyncSandevistanStart(68982241) ⇒ 客户端 /// GameSceneToClientImpl.lua:1317 做两件事: /// CostFightResource(DriveTimeScaleEnergy, 1) + StartGamePlayPause_ChangeOthersTimeSpeed(speed)。 /// ⛑ 别再拿 BattleMgr.lua:414 CheckShowJobSpecialBtn 当判据 —— 那是步行 HUD 那条, /// 它的两个调用点(CoreHudCharacterControlStore.lua:286 / :3161)都是无参调用, /// enable==nil ⇒ show 恒 nil ⇒ 只会下发 nil。它不是骑乘这条路。 /// /// 为什么服务端必须参与:IsClientDecide=true 只说明客户端自己会摘 buff, /// 世界时间缩放 freezes 与收发制 empirically 由服务端这两条 S2C 决定: /// · SyncSandevistanStart(68982241) = 单个 float「speed」—— 填 0.2 表示把 /// 世界单位 / 车流降到 0.2 倍、同时清掉受控玩家自身的 pause ⇒ 视觉上「我变快、周围变慢」。 /// 0.2 是本服调参,dump 里没有白靖的原始倍率。 /// · SyncSandevistanEnd(68549031) 无参数。 /// ⛑ 结束必须成对发,否则玩家会永久卡在慢世界里。 /// internal sealed partial class GameRouter { // 0.2 = 世界时间缩放倍率(本服调参;数值来源见文件注释)。 private const float BaiJingTimeSpeed4229938 = 0.2f; private const int BaiJingTimeDurationMs4229938 = 5_000; private const int BaiJingTimeCooldownMs4229938 = 30_000; private const uint BaiJingTimeResource4229938 = 4; private static readonly ConditionalWeakTable BaiJingTime4229938 = new(); private sealed class BaiJingTimeRuntime4229938 { internal readonly SemaphoreSlim Gate = new(1, 1); internal ulong UnitId; internal uint BuffInstanceId; internal long Serial; internal long NextReadyAt; } /// 会话是否还活着。本仓 TcpSession 没有 ClosedToken,只能用连接状态判断。 private static bool SessionAlive4229938(TcpSession session) => session.Client is { Connected: true }; /// /// 起一次斯安威斯坦。 必须是当前实际操控单位, /// 角色不对 / 单位不对 / 冷却没到 / 正在切人切场景 ⇒ 一律不生效(返回 false)。 /// internal static async Task TryStartBaiJingTime4229938(TcpSession session, ulong unitId, string reason) { var state = GetStateIfExists(session); if (state is null || !state.Ready || !state.CombatProfilePublished || state.ActiveSpiritTemplateId != CharacterCapabilities.BaiJingSpiritId || unitId == 0 || unitId != state.ActiveSpiritUnitId || state.PendingSwitchTemplateId != 0 || state.PendingTeleportId != 0) return false; var runtime = BaiJingTime4229938.GetValue(session, _ => new()); await runtime.Gate.WaitAsync(CancellationToken.None); try { // 过了异步闸再判一次:切人 / 切场景可能刚好抢先。 if (!state.Ready || state.ActiveSpiritTemplateId != CharacterCapabilities.BaiJingSpiritId || unitId != state.ActiveSpiritUnitId || state.PendingSwitchTemplateId != 0 || state.PendingTeleportId != 0) return false; var now = Environment.TickCount64; if (runtime.UnitId != 0 || now < runtime.NextReadyAt) { session.Log.Info($"[BAIJING-ZONE] not-ready remainingMs={Math.Max(0, runtime.NextReadyAt - now)} reason={reason}"); return false; } uint instanceId; lock (state.SyncRoot) instanceId = state.NextBuffInstanceId++; runtime.UnitId = unitId; runtime.BuffInstanceId = instanceId; runtime.NextReadyAt = now + BaiJingTimeCooldownMs4229938; var serial = ++runtime.Serial; var generation = state.WorldEntryControlGeneration; try { // ⛑ 不能用 WorldCodec.UnitAddBuff:它把 Permanent 置 true,5 秒后掉不下来。 await session.NotifyAsync(MethodId.SyncUnitAddBuff, UxSerializer.Serialize(new SceneMethods.SyncUnitAddBuff { entityId = unitId, buff = new SceneMethods.BuffViewData { InstanceId = instanceId, Id = CharacterCapabilities.BaiJingTimeScaleActiveBuff, ReleaserId = unitId, Tier = 1, Permanent = false, ExpireTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000d + BaiJingTimeDurationMs4229938 / 1000d, }, }), CancellationToken.None); // 只有这一个 Single,没有对象标记字节。 await session.NotifyAsync(MethodId.SyncSandevistanStart, UxSerializer.Serialize(BaiJingTimeSpeed4229938), CancellationToken.None); // 能量:先解锁(free=false)再扣到 0,30 秒后补回 1(见生命周期尾部)。 await session.NotifyAsync(MethodId.SyncFightResourceFreeState, UxSerializer.Serialize(CombatCodec.FightResourceFreeState(unitId, BaiJingTimeResource4229938, false)), CancellationToken.None); await session.NotifyAsync(MethodId.SyncFightResource, UxSerializer.Serialize(CombatCodec.FightResource(unitId, BaiJingTimeResource4229938, 0)), CancellationToken.None); } catch { runtime.NextReadyAt = 0; // Start 只发了一半就失败的情形:补一次 End,别把玩家留在慢世界里。 try { await EndBaiJingTimeUnderGate4229938(session, runtime, "start-failed"); } catch { /* 保留原始传输异常 */ } throw; } _ = RunBaiJingTimeLifecycle4229938(session, runtime, serial, generation, now + BaiJingTimeDurationMs4229938, runtime.NextReadyAt); session.Log.Info($"[BAIJING-ZONE] start unit={unitId} speed={BaiJingTimeSpeed4229938} " + $"durationMs={BaiJingTimeDurationMs4229938} cooldownMs={BaiJingTimeCooldownMs4229938} reason={reason}"); return true; } finally { runtime.Gate.Release(); } } /// /// 5 秒到点了自己收(reason=expired);期间只要世界/角色变了立刻收(reason=world-transition)。 /// 短周期 watchdog 是为了覆盖那些不走标准切人/切场景入口的路径。 /// private static async Task RunBaiJingTimeLifecycle4229938(TcpSession session, BaiJingTimeRuntime4229938 runtime, long serial, int generation, long endAt, long readyAt) { try { while (SessionAlive4229938(session)) { await Task.Delay(100, CancellationToken.None); await runtime.Gate.WaitAsync(CancellationToken.None); try { if (runtime.Serial != serial) return; if (runtime.UnitId == 0) break; var state = GetStateIfExists(session); var changed = state is null || !state.Ready || state.WorldEntryControlGeneration != generation || state.ActiveSpiritTemplateId != CharacterCapabilities.BaiJingSpiritId || state.ActiveSpiritUnitId != runtime.UnitId || state.PendingSwitchTemplateId != 0 || state.PendingTeleportId != 0; if (!changed && Environment.TickCount64 < endAt) continue; await EndBaiJingTimeUnderGate4229938(session, runtime, changed ? "world-transition" : "expired"); break; } finally { runtime.Gate.Release(); } } // 冷却走完把那一点能量补回去(SkillResourcesConfig Id=4 的 Max=1)。 var remaining = readyAt - Environment.TickCount64; if (remaining > 0) await Task.Delay(TimeSpan.FromMilliseconds(remaining), CancellationToken.None); if (!SessionAlive4229938(session)) return; await runtime.Gate.WaitAsync(CancellationToken.None); try { var state = GetStateIfExists(session); if (runtime.Serial != serial || runtime.UnitId != 0 || state is null || !state.Ready || state.WorldEntryControlGeneration != generation || state.ActiveSpiritTemplateId != CharacterCapabilities.BaiJingSpiritId || state.PendingSwitchTemplateId != 0 || state.PendingTeleportId != 0) return; await session.NotifyAsync(MethodId.SyncFightResource, UxSerializer.Serialize(CombatCodec.FightResource(state.ActiveSpiritUnitId, BaiJingTimeResource4229938, 1)), CancellationToken.None); session.Log.Info("[BAIJING-ZONE] ready"); } finally { runtime.Gate.Release(); } } catch (Exception ex) { // 会话断开是正常退出路径,不算故障。 if (SessionAlive4229938(session)) { session.Log.Warn($"[BAIJING-ZONE] lifecycle failed: {ex.Message}"); try { await StopBaiJingTime4229938(session, "lifecycle-error"); } catch (Exception cleanup) { session.Log.Warn($"[BAIJING-ZONE] cleanup failed: {cleanup.Message}"); } } } } /// 对外统一的停止入口(切人 / 切场景 / 客户端摘 buff / 出错清理)。 internal static async Task StopBaiJingTime4229938(TcpSession session, string reason) { if (!BaiJingTime4229938.TryGetValue(session, out var runtime)) return; await runtime.Gate.WaitAsync(CancellationToken.None); try { await EndBaiJingTimeUnderGate4229938(session, runtime, reason); } finally { runtime.Gate.Release(); } } private static async Task EndBaiJingTimeUnderGate4229938(TcpSession session, BaiJingTimeRuntime4229938 runtime, string reason) { if (runtime.UnitId == 0) return; var unitId = runtime.UnitId; var instanceId = runtime.BuffInstanceId; // End 无参数;先发它再摘 VFX —— 顺序反了会留下一个没有 buff 的慢世界。 // 传输失败时保留 active 状态,让后续清理有机会重试恢复时间流速。 await session.NotifyAsync(MethodId.SyncSandevistanEnd, Array.Empty(), CancellationToken.None); await session.NotifyAsync(MethodId.SyncUnitRemoveBuff, UxSerializer.Serialize(WorldCodec.UnitRemoveBuff(unitId, instanceId)), CancellationToken.None); runtime.UnitId = 0; runtime.BuffInstanceId = 0; session.Log.Info($"[BAIJING-ZONE] end unit={unitId} reason={reason}"); } }