Files

222 lines
13 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.Server.Configuration;
namespace Ananta.Server.Gameplay;
/// <summary>
/// Protagonist traversal policy.
/// All editable ids live in config/private-server.json under gameplay.webTraversal.
/// This class only answers gameplay questions for the handlers.
/// </summary>
internal static class WebTraversal
{
private static WebTraversalSettings Settings => PrivateServerConfigStore.Current.Gameplay.WebTraversal;
internal static uint MaleProtagonistId => Settings.MaleProtagonistId;
internal static uint FemaleProtagonistId => Settings.FemaleProtagonistId;
internal static uint FeiSuoBuff => Settings.PersistentGrappleBuffId;
// Build 4229938 web traversal split:
// - Swing/WallRush are client-owned action lifecycle buffs and must NOT be permanent in the login snapshot.
// - GrappleDiveDamage is a safe, long-lived capability modifier and can be part of the resident kit.
internal const uint SwingBuff4229938 = 52_853_761;
internal const uint WallRushBuff4229938 = 52_853_762;
internal const uint GrappleDiveDamageBuff4229938 = 52_959_114;
private static readonly uint[] ClientLifecycleBuffs4229938 = [SwingBuff4229938, WallRushBuff4229938];
private static readonly uint[] ResidentBuildWebBuffs4229938 = [GrappleDiveDamageBuff4229938];
internal static int GrappleRearmDelayMs => Settings.GrappleRearmDelayMs;
internal static IReadOnlyList<uint> ForbidStates => Settings.ForbidStateIds;
/// <summary>
/// 主角原生飞索能力门(C 键定点飞跃)。
///
/// <c>AbilityLimitConfig.HasFeisuoAbility = 3013</c>(<c>dump.cs:1026235</c>),挂上后客户端
/// 追加 <c>State_HasFeisuoAbility = 2318</c>(<c>dump.cs:1226889</c>)并放开 C 键与原生目标查询。
/// 这一行带 <c>GamePlayTags[] / ButtonEnum[] / QueryId</c> ⇒ 是<b>能力开关</b>,不是「限制」。
///
/// ⛑⛑ 光有飞索 buff(<see cref="FeiSuoBuff"/>)打不开客户端的原生飞索 HUD:本服日志里
/// <c>AskEnterFeiSuoCrouch</c> 命中恒为 0,即客户端从来没进过飞索蹲伏态。必须再发一次
/// <c>SyncPaokuLimitChange</c>(68974941)(见 <c>GameRouter.Profiles.PublishProtagonistGrapnelGate4229938</c>)。
/// </summary>
internal const uint DefaultFeisuoAbilityLimitId = 3013;
internal static uint FeisuoAbilityLimitId
=> Settings.FeisuoAbilityLimitId != 0 ? Settings.FeisuoAbilityLimitId : DefaultFeisuoAbilityLimitId;
/// <summary>
/// 暗杀能力门 <c>AbilityLimitConfig.UnlockAssassin = 5051</c>(<c>dump.cs:1026213</c>)
/// → 挂 <c>State_UnlockAssassin = 1333</c>(<c>dump.cs:1226857</c>)。
///
/// ⛑⛑ 与飞索门 3013 <b>同表同构但互相独立</b>:只发 3013 的话客户端能进飞索蹲伏态
/// (<c>AskEnterFeiSuoCrouch</c> 正常上报),但 <c>StealthManager</c> 的
/// <c>CrouchAssassinationUpdate</c> 不启用 ⇒ <b>从不发 ReportCanAssassinateEnemies</b>
/// ⇒ 服务端就算回了 SyncEnemyCanAssassinate 也没用(2026-09-26 17:4x 实机验证:
/// AskEnterFeiSuoCrouch 命中 19 次,ReportCanAssassinateEnemies 命中 0 次)。
/// 两者都通过同一个 <c>SyncPaokuLimitChange</c>(68974941) 下发。
/// </summary>
internal const uint DefaultAssassinationAbilityLimitId = 5051;
internal static uint AssassinationAbilityLimitId
=> Settings.AssassinationAbilityLimitId != 0 ? Settings.AssassinationAbilityLimitId : DefaultAssassinationAbilityLimitId;
/// <summary>是否下发暗杀能力门。关掉即回滚(HUD 无暗杀提示)。</summary>
internal static bool PushAssassinationAbilityGate => Settings.PushAssassinationAbilityGate;
// ------------------------------------------------------------------
// 璎珑(Yinglong / 15021040)专属「虐杀原形跑酷」门。
//
// 取证链(2026-10-01,全部用我们自己的 dump + ConfigDump,不依赖捐赠端):
// · AbilityLimitConfig.Id=3040 Name=KillingPrototype Desc="开启樱珑八步赶蝉(史晨阳)"
// → GamePlayTags=[1306] = State_CanKilling(GameplayTagConfig)。
// · 反向门 Id=3032 ForbidKilling「禁止璎珑跑酷」→ 1331 State_ForbidKilling(本服从不发)。
// · 动作态:ParkourState 10062「璎珑跑酷」(zone 1062 P_Killing)、10081「八步赶蝉jump」;
// Killing tag 族 574 Rush / 575 Jump / 577 Edge / 2502 ClimbRun / 2503 ClimbStay
// ⇒ 能力本身覆盖平地疾跑与跳跃,不是只有攀爬。
// · 服务端此前只发 3013/5051,且 PublishProtagonistGrapnelGate4229938 有 IsProtagonist 闸
// ⇒ 3040 一次都没下发过(全量日志 abilities=[3013,5051] ×351,ParkourState 10062 命中 0 次)。
// ------------------------------------------------------------------
/// <summary><c>AbilityLimitConfig.KillingPrototype</c> 的 id —— 3040。填 0 = 用内置默认 3040。</summary>
internal const uint DefaultKillingAbilityLimitId = 3040;
internal static uint KillingAbilityLimitId
=> Settings.KillingAbilityLimitId != 0 ? Settings.KillingAbilityLimitId : DefaultKillingAbilityLimitId;
/// <summary>唯一拿到这张门的角色 —— 15021040 璎珑(Yinglong)。填 0 = 用内置默认。</summary>
internal const uint DefaultKillingSpiritId = 15021040;
internal static uint KillingSpiritId
=> Settings.KillingSpiritId != 0 ? Settings.KillingSpiritId : DefaultKillingSpiritId;
/// <summary>是否下发璎珑跑酷门。关掉即回滚(回到只有基础爬墙跑/空中冲刺)。</summary>
internal static bool PushKillingAbilityGate => Settings.PushKillingAbilityGate;
/// <summary>
/// 角色 → 专属能力门列表(<c>AbilityLimitConfig.Id</c>),走同一条 <c>SyncPaokuLimitChange</c>。
///
/// 这是上面那三个「killing*」单值开关的<b>通用化替代</b>:一个角色可以带多张门。
/// 当前用途:15021040 璎珑 → 3040 虐杀原形跑酷;15020990 → 5040 连跳 + 5052 大跳。
/// 优先级:<b>字典里有该角色就用字典</b>,查不到才回落 killing* 单值配置。
///
/// ⛑ 只给配进来的角色发:其余角色即便挂上 tag,也未必有对应动画数据
/// (例:Killing 需要 <c>MovementModeType.Yinglong</c>(10) 的 ABP)。
/// ⛑ 空的字典 = 只走旧的 killing 单值路径,行为与改动前一致 ⇒ 回滚安全。
/// </summary>
internal static IReadOnlyList<uint> AbilityGatesForSpirit(uint templateId)
{
if (Settings.SpiritAbilityGates.TryGetValue(templateId, out var ids) && ids is { Length: > 0 })
return ids;
return PushKillingAbilityGate && templateId == KillingSpiritId
? new[] { KillingAbilityLimitId }
: [];
}
/// <summary>★ 跑酷状态 → 临时能力门(<c>gameplay.webTraversal.parkourStateGates</c>)。
///
/// <b>严格跟状态</b>:进入该 ParkourState 才下发,离开就 <c>isAdd=false</c> 撤回。
/// 与 <see cref="AbilityGatesForSpirit"/>(角色常驻门)是两条独立的路径,
/// 同一个 limitId 可以同时出现在两边(常驻下发一次 + 状态进入时再下发一次,客户端按集合去重)。
/// 留空字典 = 整层不生效 ⇒ 回滚安全。</summary>
internal static IReadOnlyList<uint> ParkourStateGateIds(uint parkourState)
=> Settings.ParkourStateGates.TryGetValue(parkourState, out var ids) && ids is { Length: > 0 }
? ids
: [];
/// <summary>是否配置了任何「跑酷状态 → 门」映射。空字典时整个 handler 分支直接跳过(零开销)。</summary>
internal static bool HasParkourStateGates => Settings.ParkourStateGates.Count > 0;
/// <summary>跑酷门是否在离开状态时撤回。<c>"onLeave"</c> = 严格跟状态;其余(默认 "session")= 只发不撤。
/// 见 <c>gameplay.webTraversal.parkourStateGateRelease</c> 的注释(瞬时态下 onLeave 会抖动到不可用)。</summary>
internal static bool ReleaseParkourGatesOnLeave
=> string.Equals(Settings.ParkourStateGateRelease, "onLeave", StringComparison.OrdinalIgnoreCase);
/// <summary>飞索 + 暗杀两张能力门,一起通过 SyncPaokuLimitChange 下发。</summary>
internal static uint[] AbilityLimitIds
{
get
{
var ids = new List<uint> { FeisuoAbilityLimitId };
if (PushAssassinationAbilityGate)
ids.Add(AssassinationAbilityLimitId);
return ids.ToArray();
}
}
/// <summary>是否下发原生飞索能力门。关掉即回滚(回到只有 buff、没有 C 键白圈的状态)。</summary>
internal static bool PushFeisuoAbilityGate => Settings.PushFeisuoAbilityGate;
/// <summary>
/// 是否回应蹲伏暗杀裁决(<c>ReportCanAssassinateEnemies</c> → <c>SyncEnemyCanAssassinate</c>)。
/// 关掉即回滚到「HUD 没有暗杀提示」的状态。
/// </summary>
internal static bool AnswerCrouchAssassination => Settings.AnswerCrouchAssassination;
/// <summary>
/// ★ 主动播种 <c>SyncEnemyCanAssassinate</c> —— 不等客户端上报(硬规则 192)。
///
/// 客户端的候选筛选是<b>自举死锁</b>:<c>PreCheckCanAss</c> 要求目标
/// <c>BattleDataModule.canAssassinate</c>(0xB1) == true,而它只能由这条 S2C 写入;
/// 这条 S2C 名义上又是 <c>ReportCanAssassinateEnemies</c> 的回应,上报却必须先过
/// <c>canAssassinate</c>。不主动播种 ⇒ 循环永远启动不了。
/// </summary>
internal static bool PushAssassinationSeed => Settings.PushAssassinationSeed;
/// <summary>播种粗筛半径(米)。</summary>
internal static float AssassinationSeedRadiusMeters
=> MathF.Max(0.5f, Settings.AssassinationSeedRadiusMeters);
/// <summary>播种扫描间隔(毫秒)。</summary>
internal static int AssassinationSeedIntervalMs
=> Math.Clamp(Settings.AssassinationSeedIntervalMs, 100, 10_000);
/// <summary>Resident build/web buffs, published only when the config keeps them on.
/// These carry real damage bonuses but one of them is the only non-hidden entry in the
/// whole capability set, which is what puts a permanent icon on the HUD.</summary>
private static uint[] ResidentBuildBuffs
=> Settings.PushResidentBuildBuffs ? ResidentBuildWebBuffs4229938 : [];
/// <summary>Capability buffs every controllable spirit gets, protagonists included.
/// See <c>gameplay.webTraversal.commonCapabilityBuffIds</c>.</summary>
internal static IReadOnlyList<uint> CommonCapabilityBuffIds => Settings.CommonCapabilityBuffIds;
/// <summary>Per-spirit overrides appended after <see cref="CommonCapabilityBuffIds"/>.
/// Empty by default — <c>CharacterCapabilities</c> already covers this data-driven.</summary>
internal static IReadOnlyList<uint> SpiritExtraBuffIds(uint templateId)
=> Settings.SpiritCapabilityBuffIds.TryGetValue(templateId, out var ids) ? ids : [];
internal static IReadOnlyList<uint> InitialCapabilityBuffIds
=> Combine(Settings.SharedBuffIds, new uint[] { Settings.PersistentGrappleBuffId }, Settings.MaleExtraBuffIds, ResidentBuildBuffs, CommonCapabilityBuffIds);
internal static IReadOnlyList<uint> FemaleCapabilityBuffIds
=> Combine(Settings.SharedBuffIds, new uint[] { Settings.PersistentGrappleBuffId }, Settings.FemaleExtraBuffIds, ResidentBuildBuffs, CommonCapabilityBuffIds);
internal static bool IsProtagonist(uint templateId)
=> templateId == Settings.MaleProtagonistId || templateId == Settings.FemaleProtagonistId;
internal static IReadOnlyList<uint> CapabilityBuffIds(uint templateId)
{
if (templateId == Settings.MaleProtagonistId)
return InitialCapabilityBuffIds;
if (templateId == Settings.FemaleProtagonistId)
return FemaleCapabilityBuffIds;
// Non-protagonist spirits used to get an empty kit, which is why the whole exploration
// kit vanished the moment you switched characters. They now receive the common kit plus
// any per-spirit override; CharacterCapabilities layers authored unlocks (Dila's
// telekinetic flight, the hacker set, ...) on top of this in Profiles.cs.
return Combine(CommonCapabilityBuffIds, SpiritExtraBuffIds(templateId));
}
internal static bool IsClientLifecycleBuff(uint buffId)
=> ClientLifecycleBuffs4229938.Contains(buffId);
internal static bool IsProtected(uint buffId)
=> Settings.SharedBuffIds.Contains(buffId)
|| Settings.MaleExtraBuffIds.Contains(buffId)
|| Settings.FemaleExtraBuffIds.Contains(buffId)
|| ResidentBuildWebBuffs4229938.Contains(buffId);
internal static bool IsWebBuff(uint buffId)
=> buffId == Settings.PersistentGrappleBuffId || IsProtected(buffId) || IsClientLifecycleBuff(buffId);
private static uint[] Combine(params IReadOnlyCollection<uint>[] groups)
=> groups.SelectMany(x => x).Distinct().ToArray();
}