Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.MechanikaSheep.cs
T

454 lines
24 KiB
C#
Raw 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.Collections.Concurrent;
using System.Runtime.CompilerServices;
using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 梅卡妮卡(Mechanika,spirit <b>15021017</b>)的召唤能力 —— <b>机械羊</b>。
///
/// <b>2026-09-27 17:2x 定稿(用户拍板):</b>
/// · 一次 E 在玩家<b>左前 / 右前 / 背后</b>各放一只(默认 3 只,旋钮可调)。
/// · <b>取消 10 米判定</b>:不再有"范围内已有羊就走念力"这条,想召就召。
/// · <b>取消数量上限</b>:不再有 hard cap(旋钮 <c>mechanikaSheepMaxAlive</c>,0 = 不限制)。
/// · <b>羊不会移动</b> —— 这是客户端结构决定的,不是 bug:
/// 模板 40120651 的 AI 40213004 行为树是 <c>Behavior_EmptyBehavior</c>(字面"无战斗AI"),
/// 服务端推的 <c>SyncEnemyBattleMoveTowardUnit</c> 全被忽略(实机 150+ 条,坐标 3 分钟零变化;
/// 客户端 <c>AskUnitMoveActionSimple</c> 仍在正常上报这只羊,说明它<b>在场</b>只是不动)。
/// 带真树(<c>MeikaShip</c>)的羊模板模型是透明的,客户端里也没有这棵树 ⇒ 换模板救不了。
/// ⇒ <b>跟随/移动相关代码已整块删除</b>,羊就是定点召唤物。
///
/// <b>取证(2026-09-27 读 <c>DRMK-PS_09_7k-v0.5</c> + 客户端配置表):</b>
/// · <c>SkillConfig 51937722</c>「梅卡定制召唤羊」SkillCastTypeTag=2。
/// · <c>SkillJumpConfig 97011851</c>:<c>return !IsHaveEnemyByDistance(40980002, 10);</c> → 跳到 51937722;
/// 另一条 <c>97011078</c><c>return true</c> → 跳到 51934941。
/// ⛑⛑ 但这两个是 <b>tag=2,不是可释放的根技能</b>,且没有任何 jump 指向它们、
/// 也没有 FightSkillConfig 行把 15021017 写进 SpiritId ⇒
/// <b>这版客户端里梅卡妮卡的羊没有任何原生 E 入口</b>(捐赠端的 E 绑定是他自己加的)。
/// 现做法:借 <b>51939201</b>(tag=6 的空壳占位技能)当 E 的壳子,服务端接住后召羊。
/// 详见 <c>GameplaySettings.MechanikaSheepActiveSkillId</c> 与硬规则 201。
///
/// <b>模板:</b><c>AgentConfig 40120651</c>「羊」GeneralModelId 86952761(<b>有模型</b>)、
/// AI 40213004、ShowHPBar=True(血条用 <c>UnitStateConfig.HideHp</c> 收掉)。
/// ⛔ 40980002 / 40120907「梅卡羊」模型 86952527 <b>在这版客户端里是透明的</b>,别再默认。
/// </summary>
internal sealed partial class GameRouter
{
private const uint MechanikaSpirit4229938 = 15_021_017;
private const uint MechanikaSummonSkill4229938 = 51_937_722; // 梅卡定制召唤羊
/// <summary>「梅卡定制念力」—— 51937721/51937722 的 <c>SkillReplace</c> 就挂在这个根技能上。</summary>
private const uint MechanikaMindRootSkill4229938 = 51_937_721;
/// <summary>「通用念力砸击连招01_特殊可破坏物」—— 原来 10 米内有羊时 <c>97011078</c> 跳到这里。</summary>
private const uint MechanikaMindStrikeSkill4229938 = 51_934_941;
/// <summary>
/// 「混厄召唤师召唤精灵1」—— <b>E 槽的壳子</b>。
/// ⛑⛑ 为什么是它:E 槽只认 <c>SkillCastTypeTag=6</c>。它 tag=6 且是<b>空壳占位技能</b>
/// (51939200–51939204 五个字段几乎全空、不召唤任何东西、只出现在 SkillConfig.json)
/// ⇒ 借它的壳发一次 AskUseSkill,<b>没有任何副作用</b>。实机已验证客户端肯发。
/// </summary>
private const uint MechanikaSheepCastProxySkill4229938 = 51_939_201;
/// <summary>「梅卡羊传送」—— 带 MeikaShip 树的模板唯一的技能(现用模板用不上,仅登记用)。</summary>
private const uint MechanikaSheepTeleportSkill4229938 = 51_984_701;
/// <summary>羊落点离玩家的距离(米)。沿用原来的 2.5 m,避免直接怼在玩家身上。</summary>
private const float MechanikaSheepDropMeters4229938 = 2.5f;
/// <summary>
/// 羊<b>落地时朝向</b>再转多少度。默认 <b>180</b>。
/// ⛑ 2026-09-27 17:3x 用户实机:按"落点方位角"直接给 facing 时羊全是<b>屁股对着玩家</b>
/// (落点在左前,羊也朝左前 = 背对站在中心的玩家)⇒ 统一再转 180° 让它们面朝玩家。
/// ⛑ 只改朝向,<b>不动落点</b>:落点仍由 <see cref="MechanikaSheepSpawnAngles"/> 决定。
/// </summary>
private const float MechanikaSheepFacingOffsetDeg4229938 = 180f;
private static readonly ConditionalWeakTable<TcpSession, MechanikaRuntime4229938> Mechanika4229938 = new();
private sealed class MechanikaRuntime4229938
{
internal readonly SemaphoreSlim Gate = new(1, 1);
internal readonly ConcurrentDictionary<ulong, MechanikaSheepState4229938> States = new();
internal ulong Owner;
internal int Generation;
}
private sealed class MechanikaSheepState4229938
{
internal long ActionUntil;
internal int SkillInstance;
}
private static bool MechanikaReady4229938(WorldEntryState? state)
=> state is { Ready: true, CombatProfilePublished: true, ActiveSpiritTemplateId: MechanikaSpirit4229938,
PendingSwitchTemplateId: 0, PendingTeleportId: 0 } && state.ActiveSpiritUnitId != 0;
/// <summary>
/// 梅卡妮卡的"羊技能族"白名单。⛑ 这四个 id <b>不在任何 FightSkillConfig 行里</b>,
/// 所以 <c>weapon.AllowsSkill</c> 一定判 false —— 必须在到达那条校验<b>之前</b>放行,
/// 否则客户端发来的 51937722 / 51934941 / 51939201 会被
/// <c>[COMBAT] reject unknown-skill</c> 吃掉。
/// </summary>
internal static bool AllowsMechanikaSheepSkill4229938(uint templateId, uint skillId)
=> templateId == MechanikaSpirit4229938 &&
(skillId == MechanikaMindRootSkill4229938 ||
skillId == MechanikaSummonSkill4229938 ||
skillId == MechanikaMindStrikeSkill4229938 ||
skillId == MechanikaSheepCastProxySkill4229938);
/// <summary>
/// 一次按键放一批机械羊(默认 3 只:左前 45° / 右前 −45° / 背后 180°)。
/// 调用方:<c>AskUseSkill(SkillId=51937722)</c>、<c>51937721</c>、<c>51939201</c>(当前 E 走的那个)
/// 与流派 <c>ActiveSkill</c> 兜底。
///
/// ⛑ 2026-09-27:<b>10 米判定与数量上限都已按用户要求删除</b> —— 按一次出一批,可以一直出。
/// 想加安全闸就配 <c>gameplay.mechanikaSheepMaxAlive</c>(0 = 不限制)。
/// </summary>
internal static async Task<(bool Ok, string Message)> SummonMechanikaSheep4229938(TcpSession session)
{
var state = GetStateIfExists(session);
if (!MechanikaReady4229938(state) || !state!.HasLastReportedPlayerTransform)
return (false, "请先切换到梅卡妮卡,进入场景并移动一下。");
var runtime = Mechanika4229938.GetValue(session, _ => new());
await runtime.Gate.WaitAsync(CancellationToken.None);
try
{
if (!MechanikaReady4229938(state)) return (false, "角色正在切换或传送。");
var gameplay = PrivateServerConfigStore.Current.Gameplay;
var maxAlive = gameplay.MechanikaSheepMaxAlive;
if (maxAlive > 0 && runtime.States.Count >= maxAlive)
return (true, $"机械羊数量已达上限 {maxAlive} 只(gameplay.mechanikaSheepMaxAlive;置 0 不限制)。");
runtime.Owner = state.ActiveSpiritUnitId;
runtime.Generation = state.WorldEntryControlGeneration;
var p = state.HasLastReportedPlayerTransform
? state.LastReportedPlayerPosition
: state.WorldEntryCreateHeroPosition;
if (!float.IsFinite(p.X + p.Y + p.Z)) return (false, "角色坐标无效。");
var facing = state.LastReportedPlayerRotation.Y;
if (!float.IsFinite(facing)) facing = 0f;
// 落点角度(度)。默认 [45, -45, 180] = 左前 / 右前 / 背后。
var angles = gameplay.MechanikaSheepSpawnAngles is { Length: > 0 } a ? a : new[] { 45, -45, 180 };
var perCast = gameplay.MechanikaSheepPerCast > 0 ? gameplay.MechanikaSheepPerCast : angles.Length;
var spawnPath = gameplay.MechanikaSheepSpawnPath;
var useHandwritten = string.Equals(spawnPath, "handwritten", StringComparison.OrdinalIgnoreCase);
var spawned = 0;
for (var i = 0; i < perCast; i++)
{
// 硬规则 181:facing 是<b>度数</b>,喂 sin/cos 前必须转弧度。
var offset = angles[i % angles.Length];
var yaw = (facing + offset) * MathF.PI / 180f;
var position = new Vec3(p.X + MathF.Sin(yaw) * MechanikaSheepDropMeters4229938,
p.Y,
p.Z + MathF.Cos(yaw) * MechanikaSheepDropMeters4229938);
// 朝向 = 落点方位角 + 180°(面朝玩家),并归一到 [0,360):玩家 facing 可能为负。
var sheepFacing = ((facing + offset + MechanikaSheepFacingOffsetDeg4229938) % 360f + 360f) % 360f;
ulong id;
if (useHandwritten)
{
id = GameAdminBridge.NextRetirableNpcUid();
}
else
{
var result = await GameAdminBridge.SpawnAgentAsync(
gameplay.MechanikaSheepAgentId, position.X, position.Y, position.Z, sheepFacing,
hostile: true, // AgentConfig 40120651 在 Hostile 桶里,校验要求 true
engage: false, // 羊不进敌人账本:它不是拿来打的
forceEnemyRelation: false, // 不能把玩家的羊标成玩家的敌人
campOverride: (byte)gameplay.MechanikaSheepCamp,
treeNameOverride: gameplay.MechanikaSheepTreeName,
occurOverride: gameplay.MechanikaSheepOccurId);
if (!result.Ok)
{
session.Log.Warn($"[MECHANIKA] spawn #{i} failed: {result.Message}");
continue;
}
id = result.Uid;
await ActivateMechanikaSheepAsync(session, id);
}
// ★ 血条:AgentConfig 40120651.ShowHPBar=True(客户端配置,服务端改不了),
// 唯一可写的开关是 UnitStateConfig.HideHp —— 机械狗就是靠它把血条收掉的
// (GameRouter.HackerSummon.cs)。详见 GameplaySettings.MechanikaSheepHideHpBar。
if (gameplay.MechanikaSheepHideHpBar)
await HideMechanikaSheepHpBarAsync(session, id);
runtime.States[id] = new MechanikaSheepState4229938();
if (useHandwritten)
{
try
{
await SendMechanikaSheepAgentAsync(session, id, runtime.Owner, position, sheepFacing);
}
catch
{
runtime.States.TryRemove(id, out _);
throw;
}
}
spawned++;
}
if (spawned == 0)
return (false, "机械羊生成失败(见上一行 [MECHANIKA] spawn 日志)。");
session.Log.Info($"[MECHANIKA] summoned sheep={spawned} template={gameplay.MechanikaSheepAgentId} "
+ $"path={spawnPath} owner={runtime.Owner} angles=[{string.Join(",", angles)}] "
+ $"alive={runtime.States.Count} at=({p.X:F2},{p.Y:F2},{p.Z:F2}) facing={facing:F2}");
return (true, $"已在身边放下 {spawned} 只机械羊(左前/右前/背后),场上共 {runtime.States.Count} 只。");
}
finally { runtime.Gate.Release(); }
}
/// <summary>
/// 生成之后的"激活批次"。<c>GameplaySettings.MechanikaSheepActivate</c>:
/// "battleAiS"(默认)只切战斗树、<b>不锁玩家</b>;"full" 走 GM 那套(会攻击玩家);"none" 不发。
/// ⛑ 1.5 s 后重发一次是照 <c>ReEngageSpawnedEnemyAsync</c> 的节奏 —— 第一批可能在客户端
/// 还没把这个单位初始化完就被吃掉了。
/// </summary>
private static async Task ActivateMechanikaSheepAsync(TcpSession session, ulong id)
{
var mode = PrivateServerConfigStore.Current.Gameplay.MechanikaSheepActivate;
if (string.Equals(mode, "none", StringComparison.OrdinalIgnoreCase))
return;
if (string.Equals(mode, "full", StringComparison.OrdinalIgnoreCase))
{
var state = GetStateIfExists(session);
var playerUnit = state is not null && state.WorldEntryControlUnit != 0
? state.WorldEntryControlUnit
: state?.ActiveSpiritUnitId ?? 0;
await EngageSpawnedEnemyAsync(session, id, playerUnit);
_ = ReEngageSpawnedEnemyAsync(session, id);
session.Log.Info($"[MECHANIKA] activate uid={id} mode=full (GM 那套;羊会攻击玩家)");
return;
}
try
{
await session.NotifyAsync(MethodId.SyncBattleAiS,
UxSerializer.Serialize(new SceneMethods.SyncBattleAiS { agentId = id, value = true }),
CancellationToken.None);
await session.NotifyAsync(MethodId.SyncEnemyDetect,
UxSerializer.Serialize(new GameMethods.EnemyDetectData4229938 { Id = id, Stealth = 0 }),
CancellationToken.None);
session.Log.Info($"[MECHANIKA] activate uid={id} mode=battleAiS (切战斗树,不锁玩家)");
}
catch (Exception ex)
{
session.Log.Warn($"[MECHANIKA] activate failed uid={id}: {ex.Message}");
return;
}
// 1.5 s 后补一次(对齐 ReEngageSpawnedEnemyAsync 的节奏)。
_ = Task.Run(async () =>
{
try
{
await Task.Delay(1500, CancellationToken.None);
if (!SessionAlive4229938(session)) return;
await session.NotifyAsync(MethodId.SyncBattleAiS,
UxSerializer.Serialize(new SceneMethods.SyncBattleAiS { agentId = id, value = true }),
CancellationToken.None);
session.Log.Info($"[MECHANIKA] re-activate uid={id} mode=battleAiS");
}
catch (Exception ex)
{
session.Log.Warn($"[MECHANIKA] re-activate failed uid={id}: {ex.Message}");
}
});
}
/// <summary>
/// 收掉羊头顶的血条(<c>SyncAddUnitState</c> + <c>UnitStateConfig.HideHp = 10266</c>)。
/// 与 <c>GameRouter.HackerSummon.cs</c> 给机械狗做的<b>完全同一招</b>。
/// ⛑ 1.5 s 后重发一次:spawn 批是几十个包,AddUnitState 可能比单位在客户端建好更早到,
/// 那就白发了(<c>ReassertHiddenHpBarAsync</c> / <c>ReEngageSpawnedEnemyAsync</c> 同样延 1.5 s)。
/// </summary>
private static async Task HideMechanikaSheepHpBarAsync(TcpSession session, ulong id)
{
async Task Send()
{
await session.NotifyAsync(MethodId.SyncAddUnitState,
UxSerializer.Serialize(new GameMethods.SyncAddUnitStateArgs4229938
{
Id = id,
State = LocalEnemyUnitStateHideHp,
EffectFreezeState = 0u,
}), CancellationToken.None);
}
try
{
await Send();
session.Log.Info($"[MECHANIKA] hide hp bar uid={id} state=HideHp({LocalEnemyUnitStateHideHp})");
}
catch (Exception ex)
{
session.Log.Warn($"[MECHANIKA] hide hp bar failed uid={id}: {ex.Message}");
return;
}
_ = Task.Run(async () =>
{
try
{
await Task.Delay(1500, CancellationToken.None);
if (!SessionAlive4229938(session)) return;
await Send();
session.Log.Info($"[MECHANIKA] reassert hide hp bar uid={id}");
}
catch (Exception ex)
{
session.Log.Warn($"[MECHANIKA] reassert hide hp bar failed uid={id}: {ex.Message}");
}
});
}
/// <summary>
/// 刷一只羊(<c>mechanikaSheepSpawnPath="handwritten"</c> 时走这条)。
/// 序列照抄 <c>GameRouter.Civilians.cs</c> 的
/// <c>SendLeftBehindAgentAsync</c>(<c>SyncRaidBattleUnitAgent</c> 通道,实机验证过的那条)。
/// ⛑ 阵营:硬规则 132 —— 友方 NPC 一律不发 <c>SyncForceEnemyRelation</c>、不登记进敌人账本。
/// </summary>
private static async Task SendMechanikaSheepAgentAsync(TcpSession session, ulong id, ulong owner,
Vec3 position, float facing)
{
var gameplay = PrivateServerConfigStore.Current.Gameplay;
var ai = new SceneMethods.LinkAIAgentInfo { Uid = id, Nickname = "梅卡羊", Fashion = null };
var syncInfo = new SceneMethods.AgentSyncClientInfo
{
isApproachNpc = true,
TriggerLeaveEvent = true,
approachDistance = 300,
LeaveDistance = 600,
sitIndex = -1,
SpoonAgentId = -1,
treeName = string.IsNullOrWhiteSpace(gameplay.MechanikaSheepTreeName)
? "Main_Common"
: gameplay.MechanikaSheepTreeName,
AISetting = 40_213_004,
stimIDList = [],
spawnEffectId = [],
indoorList = [],
roomIds = [],
returnPoiActionIds = [],
AIAgentInfo = ai,
};
await session.NotifyAsync(MethodId.SyncLogicAgentEnter,
UxSerializer.Serialize(WorldCodec.LogicAgentEnter(id)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncManagedLogicAgent,
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(id, Profile.PlayerPid, 0)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncRaidBattleUnitAgent,
UxSerializer.Serialize(new SceneMethods.RaidBattleUnitAgent
{
SkillId = -1,
HSummonIndex = -1,
SpoonAgentId = -1,
FashionIdList = [],
VehicleIndex = -1,
IsBorn = true,
BattleAiS = true,
agentSyncClientInfo = syncInfo,
WeaponId = 98005506u,
SpawnType = 1,
AIAgentInfo = ai,
Id = id,
TemplateId = gameplay.MechanikaSheepAgentId,
Position = new Auto.UXVector3 { X = position.X, Y = position.Y, Z = position.Z },
FacingDirection = facing,
ManagedPid = Profile.PlayerPid,
GroundData = new Auto.MoveActionGroundData { LocalPos = new Auto.UXVector3() },
}), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncUnitOccur,
UxSerializer.Serialize(new SceneMethods.SyncUnitOccur
{
enemyId = id,
occurId = gameplay.MechanikaSheepOccurId,
dullSecs = 0f,
position = new Auto.UXVector3 { X = position.X, Y = position.Y, Z = position.Z },
rotateY = facing,
}), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncAgentCampInfo,
UxSerializer.Serialize(new SceneMethods.SyncAgentCampInfo { pid = id, camp = (byte)gameplay.MechanikaSheepCamp }), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncAttachBattleModule,
UxSerializer.Serialize(new SceneMethods.SyncAttachBattleModule { unitId = id }), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncUnitAttrs,
UxSerializer.Serialize(new SceneMethods.SyncUnitAttrs
{
unitId = id,
attrs = new Dictionary<uint, float> { [1] = 100f },
}), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncUnitHp,
UxSerializer.Serialize(new SceneMethods.SyncUnitHp { unitId = id, hp = 100f }), CancellationToken.None);
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938 { id = id, isForceGo = true }),
CancellationToken.None);
}
/// <summary>
/// 羊自己放的原生技能(MeikaShip 只列了传送 51984701):只登记生命周期,
/// <b>绝扣不到玩家头上</b>,也绝不伪造伤害/命中。
/// ⛑ 现用的 40120651 行为树是 <c>Behavior_EmptyBehavior</c>,实际上不会放技能;
/// 这条留着是为了万一换成带树的模板时,不把羊的技能记到玩家连招账上。
/// </summary>
internal static async Task<bool> RecordMechanikaSkill4229938(TcpSession session, SceneMethods.SkillUseData request)
{
if (!Mechanika4229938.TryGetValue(session, out var runtime)) return false;
if (!runtime.States.TryGetValue(request.Releaser, out var sheep)
|| request.SkillId != MechanikaSheepTeleportSkill4229938) return false;
var state = GetStateIfExists(session);
if (!MechanikaReady4229938(state) || runtime.Generation != state!.WorldEntryControlGeneration
|| runtime.Owner != state.ActiveSpiritUnitId) return false;
sheep.SkillInstance = request.SkillInstanceId;
sheep.ActionUntil = Environment.TickCount64 + 5000;
session.Log.Info($"[MECHANIKA] native-skill entity={request.Releaser} skill={request.SkillId}; player resources untouched");
await Task.CompletedTask;
return true;
}
/// <summary>技能收尾:把冷冻再延长一点点,避免刚传送走的羊被别处逻辑打断。</summary>
internal static void RecordMechanikaSkillEnd4229938(TcpSession session, SceneMethods.ReportSkillEnd report)
{
if (!Mechanika4229938.TryGetValue(session, out var runtime)) return;
if (!runtime.States.TryGetValue(report.unitId, out var sheep) || report.skillId != sheep.SkillInstance) return;
sheep.ActionUntil = Environment.TickCount64 + 500;
}
private static async Task ClearMechanikaSheepUnderGate4229938(TcpSession session, MechanikaRuntime4229938 runtime)
{
var ids = runtime.States.Keys.ToArray();
runtime.States.Clear();
runtime.Owner = 0;
// 世界已经换代 ⇒ 旧 id 不要再往新世界送 Leave。
if (GetStateIfExists(session)?.WorldEntryControlGeneration != runtime.Generation) return;
foreach (var id in ids)
await session.NotifyAsync(MethodId.SyncLogicAgentLeave,
UxSerializer.Serialize(WorldCodec.LogicAgentLeave(id)), CancellationToken.None);
session.Log.Info($"[MECHANIKA] recalled count={ids.Length}");
}
/// <summary>对外统一召回入口(切角色 / 切场景)。</summary>
internal static async Task StopMechanikaSheep4229938(TcpSession session)
{
if (!Mechanika4229938.TryGetValue(session, out var runtime)) return;
await runtime.Gate.WaitAsync(CancellationToken.None);
try { await ClearMechanikaSheepUnderGate4229938(session, runtime); }
finally { runtime.Gate.Release(); }
}
}