Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.BaiJingTime.cs

239 lines
14 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 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}");
}
}