222 lines
13 KiB
C#
222 lines
13 KiB
C#
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();
|
||
}
|