238 lines
14 KiB
C#
238 lines
14 KiB
C#
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;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 白靖(Bai Jing,spirit <b>15021039</b>)的原生能力 —— 斯安威斯坦(Sandevistan / 子弹时间)。
|
|||
|
|
///
|
|||
|
|
/// <b>取证</b>(2026-09-26 03:1x 只读调研 + 2026-09-27 复核捐赠端 7k-v0.5 的同名实现):
|
|||
|
|
/// · <c>BuffConfig 52959495</c>「可以激活斯安威斯坦」Duration <b>-1</b>、IsHidden、Description
|
|||
|
|
/// 「职业标记buff」⇒ 这是<b>能力门闸</b>,由 <c>CharacterCapabilities</c> 作为常驻 buff 下发给白靖。
|
|||
|
|
/// · <c>BuffConfig 52959496</c>「斯安威斯坦」Duration <b>5 s</b>、Description「子弹时间」、
|
|||
|
|
/// EffectIds [53500525]、IsClientDecide=true ⇒ 真正生效那一份,5 秒到期自己掉。
|
|||
|
|
/// · <c>SkillResourcesConfig Id=4</c>「赛车手职业时缓次数」Max 1 / Default 1 / <b>Interval 30</b>
|
|||
|
|
/// ⇒ 原生就是「一点能量、30 秒回满」,本文件的 5 s / 30 s 与之对齐,不是自造数值。
|
|||
|
|
/// · 白靖的原生职业 = <b>赛车手</b>(class 11300002 / job 4 Racer)。
|
|||
|
|
///
|
|||
|
|
/// <b>触发:只剩原生那一条(2026-09-27 用户拍板)。</b>
|
|||
|
|
/// 客户端按下职业技能按钮 ⇒ <c>AskAddClientBuff(unitId, 52959496)</c> ⇒ 本文件
|
|||
|
|
/// <c>TryStartBaiJingTime4229938</c>;客户端收起(或 buff 自然到期后再次上报摘除)⇒
|
|||
|
|
/// <c>AskRemoveClientBuff(52959496)</c> ⇒ <c>StopBaiJingTime4229938</c>。
|
|||
|
|
/// ⛔ 移植时<b>刻意删掉了捐赠端的第二条触发</b>(挂到当前流派 <c>ActiveSkill</c> 上、reason="active-skill"):
|
|||
|
|
/// 那会让玩家每放一次 E 顺带进一次子弹时间,与原生语义不符 —— 原生是职业技能,不是战技。
|
|||
|
|
/// <b>按钮到底怎么出来的(2026-09-27 二次取证,推翻上一次的判断):</b>
|
|||
|
|
/// · 真正管用的是<b>驾驶 HUD</b>:<c>DriveControlDriverStore.lua</c>
|
|||
|
|
/// <c>InitButtonValidCheck</c> ⇒ <c>BTN_VALID.PROFESSION_SKILL = self:ShowProfessionSkillButton()</c>
|
|||
|
|
/// 而 <c>ShowProfessionSkillButton</c> 只有一行:
|
|||
|
|
/// <c>return PlayerUnit and gBuffUtils.HasBuff(Pid, BuffConfig.TimeScaleBadgeBuff)</c>
|
|||
|
|
/// ⇒ <b>骑上载具后按钮出不出来,只取决于玩家身上有没有那个门闸 buff,不查职业。</b>
|
|||
|
|
/// · 按下 ⇒ <c>BattleMgr.lua:580 ClickProfessionSkill()</c> ⇒
|
|||
|
|
/// <c>AskAddClientBuff(Pid, 52959496)</c>(id 在 lua 里是硬编码的,跟我们的常量一致)。
|
|||
|
|
/// · 服务端回 <c>SyncSandevistanStart</c>(68982241) ⇒ 客户端
|
|||
|
|
/// <c>GameSceneToClientImpl.lua:1317</c> 做两件事:
|
|||
|
|
/// <c>CostFightResource(DriveTimeScaleEnergy, 1)</c> + <c>StartGamePlayPause_ChangeOthersTimeSpeed(speed)</c>。
|
|||
|
|
/// ⛑ 别再拿 <c>BattleMgr.lua:414 CheckShowJobSpecialBtn</c> 当判据 —— 那是<b>步行</b> HUD 那条,
|
|||
|
|
/// 它的两个调用点(CoreHudCharacterControlStore.lua:286 / :3161)都是<b>无参调用</b>,
|
|||
|
|
/// <c>enable==nil</c> ⇒ <c>show</c> 恒 nil ⇒ 只会下发 nil。它不是骑乘这条路。
|
|||
|
|
///
|
|||
|
|
/// <b>为什么服务端必须参与:</b>IsClientDecide=true 只说明客户端自己会摘 buff,
|
|||
|
|
/// 世界时间缩放 freezes 与收发制 empirically 由服务端这两条 S2C 决定:
|
|||
|
|
/// · <c>SyncSandevistanStart</c>(68982241) = 单个 float「speed」—— 填 <b>0.2</b> 表示把
|
|||
|
|
/// 世界单位 / 车流降到 0.2 倍、同时清掉受控玩家自身的 pause ⇒ 视觉上「我变快、周围变慢」。
|
|||
|
|
/// 0.2 是本服调参,dump 里没有白靖的原始倍率。
|
|||
|
|
/// · <c>SyncSandevistanEnd</c>(68549031) 无参数。
|
|||
|
|
/// ⛑ 结束必须成对发,否则玩家会永久卡在慢世界里。
|
|||
|
|
/// </summary>
|
|||
|
|
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<TcpSession, BaiJingTimeRuntime4229938> 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>会话是否还活着。本仓 <c>TcpSession</c> 没有 <c>ClosedToken</c>,只能用连接状态判断。</summary>
|
|||
|
|
private static bool SessionAlive4229938(TcpSession session)
|
|||
|
|
=> session.Client is { Connected: true };
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 起一次斯安威斯坦。<paramref name="unitId"/> 必须是当前实际操控单位,
|
|||
|
|
/// 角色不对 / 单位不对 / 冷却没到 / 正在切人切场景 ⇒ 一律不生效(返回 false)。
|
|||
|
|
/// </summary>
|
|||
|
|
internal static async Task<bool> 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(); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 5 秒到点了自己收(reason=expired);期间只要世界/角色变了立刻收(reason=world-transition)。
|
|||
|
|
/// 短周期 watchdog 是为了覆盖那些不走标准切人/切场景入口的路径。
|
|||
|
|
/// </summary>
|
|||
|
|
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}"); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>对外统一的停止入口(切人 / 切场景 / 客户端摘 buff / 出错清理)。</summary>
|
|||
|
|
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<byte>(), 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}");
|
|||
|
|
}
|
|||
|
|
}
|