Files

366 lines
20 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.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 厕所 QTE / 马桶鸵鸟骑乘(2026-09-30 新增)。
///
/// <para>★ 玩法本体:<c>ToiletOstrichGamePanelStore.lua</c> —— 骑上马桶鸵鸟后弹一个音游式三键 QTE
/// (<c>QTE_DURATION=2</c> / <c>PERFECT_WINDOW=0.1</c> / <c>GREAT_WINDOW=0.2</c>,满分 <b>100</b>),
/// 失败时鸵鸟逃跑。<b>面板与判定全在客户端</b>,服务端要做的是「让骑乘坐实」+「接住逃跑上报」。</para>
///
/// <para>★ 触发链(三条证据实锤,见 <see cref="GameMethods.AskRideAgent4229938"/> 的契约注释)</para>
/// <list type="number">
/// <item>面板由 <b>C#→lua 回调</b>打开:<c>CsToLuaHandler.lua</c> 的
/// <c>module.OnStartRideQTE(riderPid, beRidenPid)</c> ⇒
/// <c>gPanelManager:CheckShow(gPanelId.TOILET_OSTRICH_GAME_PANEL, { npcPid = beRidenPid })</c>。
/// 也就是说 <b>C# 认定「骑乘成立」时才 fire</b>,lua 只是被动开面板。</item>
/// <item>面板里 <c>SceneDataMgr.GetUnit(self.npcPid)</c> ⇒ 被骑的那只必须是世界里真实存在的 unit。</item>
/// <item>失败分支会主动上报 <c>AskToiletOstrichFlee</c>;成功分支只发本地信号 5753。</item>
/// </list>
///
/// <para>★ 为什么<b>不需要新的生成通道</b></para>
/// 马桶鸵鸟(<b>40122028 / 40652007 / 40922011 / 42046401 / 45000446</b>,规范 id
/// <c>AgentConfig.ToiletOstrich = 42046401</c>)在 <c>NpcCatalog.json</c> 里是<b>标准 Agent</b>
/// (<c>GeneralModelId=86963029</c>、<c>EnemyClassType=1</c>、faction 18000300「通用怪物」),
/// 而且 <c>AgentSummonCatalog.json</c> 里本来就有它 ⇒ 直接用既有的服务端直生成通道
/// (<c>GameAdminBridge.SpawnAgentAsync</c>)就能出,客户端 ai 状态树
/// (<c>MassAI.Npc.MassAetherAI.Tasks.TryOnRideAgent / TryDownRideAgent</c>,走
/// <c>BehaviorActionConfigId 40214321</c> + <c>AISetting 40213146</c>)自带骑乘能力,服务端不用给标记。
/// 面板入口见 <c>GameAdminBridge.AgentSummonCatalog</c>(它被置顶到「生成敌人」下拉框第一位)。
///
/// <para>★ 一次只动一个(实测顺序,按这个走)</para>
/// <list type="number">
/// <item>生成 <b>42046401</b> → 走到旁边按骑乘。</item>
/// <item><b>面板出来了</b> ⇒ 本文件的默认配置已经够用,接下来看
/// <c>[TOILET]</c> 日志里 <c>flee</c> 出现时鸵鸟是否正确消失。</item>
/// <item><b>面板不出来</b> ⇒ 把 <c>gameplay.toiletQte.pushRideState</c> 打开(推
/// <c>SyncRideAgent(rider, ridee, true)</c>),这与自动驾驶那条「服务端推状态」是同一个模式;
/// ⛑ 注意 <c>GameRouter.VehicleNav.cs</c> §43.1 的包序教训:<b>状态包要排在动作包之前</b>,
/// 所以这里也是先推状态、再回 ack。</item>
/// </list>
///
/// <para>⛑ 回滚:把本文件涉及的三个 mid 从 <c>GameRouter.cs</c> 的
/// <c>Enabled4229938MethodIds</c> 里删掉即可(handler 不再注册 = 回到
/// <c>ReturnEmptyOkAsync</c> / <c>DefaultUnknownNotify</c> 兜底,不在场时行为与今天之前一致);
/// 或者一个开关 <c>gameplay.toiletQte.enabled=false</c> 只关「回头推 + 回收」,不动注册。</para>
/// </summary>
internal sealed partial class GameRouter
{
private static ToiletQteSettings ToiletCfg
=> PrivateServerConfigStore.Current.Gameplay.ToiletQte;
/// <summary>
/// 马桶鸵鸟的全部模板 id(<c>NpcCatalog.json</c> / <c>AgentSummonCatalog.json</c> 里同名同模
/// <c>GeneralModelId=86963029</c> 的 5 条)。<c>42046401</c> 是客户端常量
/// <c>AgentConfig.ToiletOstrich</c>,也是面板下拉框置顶后用的那个 id。
/// </summary>
private static readonly uint[] ToiletOstrichAgentIds =
[40122028, 40652007, 40922011, 42046401, 45000446];
/// <summary>模板是不是马桶鸵鸟。<c>GameAdminBridge.SpawnAgentAsync</c> 用它决定「生成成坐骑还是生成成敌人」。</summary>
internal static bool IsToiletOstrichAgent(uint agentId)
=> Array.IndexOf(ToiletOstrichAgentIds, agentId) >= 0;
private const string ToiletMountKey = "client4229938-toilet-mounts";
// ---------------------------------------------------------------------------------
// ★★ 骑乘的完整四步(2026-09-30 19:2x 补完,来源:v0.5-2 捐赠端 GameRouter.HackerDevices.cs:254-282)
//
// "Native Mount() -> AskRideAgent is separate from AskControlAgent/DoHackEnemy.
// UnitStateConfig 345 (BeRiddenS, behavior 10345) enters the mount's riding
// movement state; 346 marks the rider. An empty successful return alone does
// not publish either state. Do not replace this with a remote camera switch."
//
// 四步:
// ① SyncManagedLogicAgent(坐骑, ownerPid, 0) —— ⛑ 我们**不需要在上马时发**:
// GameAdminBridge.SpawnAgentAsync 生成时已经发过同一条(GameAdminBridge.cs:3155,
// 参数 agentId=uid / pid=ownerPid / moveId=0,与捐赠端逐字段一致)。
// ② SyncRideAgent(rider, ridee, hasRideOn) 17B
// ③ SyncAddUnitState(坐骑, 345) ← 被骑移动状态=驯服动画/能跑起来的开关
// ④ SyncAddUnitState(骑手, 346) ← 骑手标记
// 下马 = 反向:SyncRemoveUnitState 撤掉 345/346 + SyncRideAgent(hasRideOn=false)。
//
// ⛑ 只推 ② 不推 ③④(上一版就是这样)的表现正是用户反馈的
// 「QTE 正常,但没有驯服动画,成功也骑不起来」—— 坐骑从来没进入 BeRiddenS。
// ---------------------------------------------------------------------------------
/// <summary><c>UnitStateConfig 345 = BeRiddenS</c>(behavior 10345):坐骑进入「被骑」移动状态。</summary>
private const uint UnitStateBeRidden = 345;
/// <summary><c>UnitStateConfig 346</c>:骑手标记(挂在骑手单位上)。</summary>
private const uint UnitStateRider = 346;
private const string ToiletRideKey = "client4229938-toilet-riding";
/// <summary>当前正骑着哪只(session 级)。下马/逃跑时要靠它知道该撤哪个 uid 的状态。</summary>
private static ulong GetRidingMount(TcpSession session)
=> session.Items.TryGetValue(ToiletRideKey, out var raw) && raw is ulong uid ? uid : 0UL;
private static void SetRidingMount(TcpSession session, ulong uid)
{
if (uid == 0) session.Items.Remove(ToiletRideKey);
else session.Items[ToiletRideKey] = uid;
}
/// <summary>
/// 建立/解除完整骑乘状态(② + ③④)。顺序照抄捐赠端:<c>SyncRideAgent</c> 先,345/346 后。
/// ⛑⛑ 必须在 <c>ReturnEmptyOkAsync</c> **之前**发完 —— §43.1 的包序教训。
/// </summary>
private static async Task PublishToiletRideAsync(Connection conn, ulong rider, ulong mount, bool enter)
{
if (rider == 0 || mount == 0)
return;
await conn.NotifyAsync(MethodId.SyncRideAgent,
new GameMethods.SyncRideAgent4229938 { rider = rider, ridee = mount, hasRideOn = enter });
if (ToiletCfg.PushUnitStates)
{
foreach (var (unit, state) in new[] { (mount, UnitStateBeRidden), (rider, UnitStateRider) })
{
if (enter)
await conn.NotifyAsync(MethodId.SyncAddUnitState,
new GameMethods.SyncAddUnitStateArgs4229938 { Id = unit, State = state, EffectFreezeState = 0 });
else
await conn.NotifyAsync(MethodId.SyncRemoveUnitState,
new SceneMethods.SyncRemoveUnitState { unitId = unit, state = state });
}
}
SetRidingMount(conn.Session, enter ? mount : 0UL);
conn.Log.Info($"[TOILET] ride-state enter={enter} rider={rider} mount={mount} unitStates={(ToiletCfg.PushUnitStates ? "345/346" : "off")}");
// ★★ 2026-10-02 新增:原生坐骑操控桥(加速 / 跳跃),见 GameRouter.ToiletControls.cs。
// enter=true 下发 arm(装 HUD 钩子并武装转发);下马 / QTE 失败 / 逃跑下发 clear(卸载 + 补 Release)。
// ⛑ 状态包已经发完才轮到它 —— Lua 不是状态,晚一点发不影响骑乘成立。
if (ToiletCfg.NativeControls)
await PublishToiletControls4229938(conn.Session, rider, mount,
enter ? ToiletControlsArm : ToiletControlsClear);
}
/// <summary>
/// uid → 模板 id,**只为马桶鸵鸟**记一条。
///
/// <para>⛑ 为什么不直接查敌人账本:坐骑模式是 <c>engage=false</c>(不下令开战、不登记进
/// <c>LocalEnemyRuntimeState.Enemies</c>),账本里根本没有它;而 <c>AskRideAgent</c> /
/// <c>AskToiletOstrichFlee</c> 收到的载荷只有 unit uid,没有模板 id ⇒ 日志里
/// <c>isOstrich=?</c> 就再也判不出来了。</para>
/// 会话销毁即失效(存在 <c>session.Items</c> 里),本来也是一次性的东西。
/// </summary>
private static Dictionary<ulong, uint> ToiletMounts(TcpSession session)
{
if (session.Items.TryGetValue(ToiletMountKey, out var raw) && raw is Dictionary<ulong, uint> existing)
return existing;
var created = new Dictionary<ulong, uint>();
session.Items[ToiletMountKey] = created;
return created;
}
/// <summary>面板生成马桶鸵鸟时登记一条(由 <c>GameAdminBridge.SpawnAgentAsync</c> 调用)。</summary>
internal static void NoteToiletMountSpawned(TcpSession session, ulong uid, uint agentId)
{
if (uid == 0 || !IsToiletOstrichAgent(agentId))
return;
var map = ToiletMounts(session);
lock (map)
map[uid] = agentId;
}
/// <summary>
/// uid 是不是<b>本服自己生成</b>的(<c>GameAdminBridge.ServerSpawnUidBase</c> 起的池子)。
///
/// <para>⛑⛑ 2026-09-30 实测事故:<c>AskToiletOstrichFlee</c> 送来过 <c>uid=19000</c>,
/// 而 19000 是<b>场景自带的真实敌人</b>(<c>[LENEMY] ... uid=19000 serverSpawned=False hp=1500</c>,
/// 在 -4739,169,-2828),当时代码无条件回收 ⇒ <b>把一只无辜的场景敌人删了</b>。
/// 根因是 <c>AskRideAgent</c> 我们一律回 0 字节成功,客户端于是对<b>任何</b>可按 F 的单位
/// 都 fire <c>OnStartRideQTE</c>,失败后又对那个单位发 flee。</para>
///
/// <para>⇒ 回收的硬门槛改成「<b>只回收本服生成的单位</b>」:客户端这条 Notify 只在马桶 QTE
/// 失败时发,而我们的马桶一定是我们自己 spawn 的,两条判据交集足够;场景单位落在池子外,
/// 天然被排除。<c>ServerSpawnUidBase = 0x200000000000</c>(= 35184372088832)远高于任何
/// 场景单位 uid。</para>
/// </summary>
private static bool IsServerSpawnedUid(ulong uid)
=> uid >= GameAdminBridge.ServerSpawnUidBase;
// ========================================================================================
// AskRideAgent(67575069) —— C2S Invoke,载荷 ulong agentId,**回包必须是 0 字节**
// ========================================================================================
[Handler(MethodId.AskRideAgent)]
private async Task AskRideAgent4229938Handler(Connection conn, UxRpcMessage msg)
{
var agentId = TryReadRideTarget(msg);
var rider = CurrentRiderUnit(conn.Session);
var template = LookupEnemyTemplate(conn.Session, agentId);
var isOstrich = IsToiletOstrichAgent(template);
conn.Log.Info($"[TOILET] ride-request ridee={agentId} template={(template == 0 ? "?" : template.ToString())}" +
$" isOstrich={isOstrich} rider={rider} cfg(enabled={ToiletCfg.Enabled} pushRideState={ToiletCfg.PushRideState})");
if (ToiletCfg.Enabled && ToiletCfg.PushRideState && rider != 0 && agentId != 0)
{
// ⛑ 先推状态、再回 ack(§43.1:状态包必须在动作包之前)。
// 完整四步见 PublishToiletRideAsync 的注释:① 生成时已发 ②③④ 在这里。
await PublishToiletRideAsync(conn, rider, agentId, true);
}
if (ToiletCfg.Enabled && ToiletCfg.PushPosture && agentId != 0)
{
await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncToiletPosture,
new GameMethods.SyncToiletPosture4229938 { agentId = agentId, isSquatting = false });
}
// 回包函数体在客户端是空的(RPCDeserializeAuto.lua:15516)⇒ 0 字节才是正确形状。
await conn.ReturnEmptyOkAsync(msg);
}
// ========================================================================================
// AskStopRideAgent(67449345) —— C2S Invoke,**无参**,回包也是 0 字节
// ========================================================================================
[Handler(MethodId.AskStopRideAgent)]
private async Task AskStopRideAgent4229938Handler(Connection conn, UxRpcMessage msg)
{
var rider = CurrentRiderUnit(conn.Session);
// ⛑ 用**真实坐骑 uid**,别发 ridee=0:下马要撤的是坐骑身上那条 345,
// ridee=0 的话状态撤不掉(捐赠端 PublishPoliceDogRide4229938 同样传的是 runtime.Agent)。
var mount = GetRidingMount(conn.Session);
conn.Log.Info($"[TOILET] ride-stop rider={rider} mount={mount} body={(msg.Body is { Length: > 0 } b ? b.Length.ToString() : "0")}B");
if (ToiletCfg.Enabled && ToiletCfg.PushRideState && rider != 0 && mount != 0)
await PublishToiletRideAsync(conn, rider, mount, false);
await conn.ReturnEmptyOkAsync(msg);
}
// ========================================================================================
// AskToiletOstrichFlee(67154315) —— C2S **Notify**,载荷 ulong agentid(QTE 失败时客户端上报)
// ========================================================================================
[Handler(MethodId.AskToiletOstrichFlee)]
private async Task AskToiletOstrichFlee4229938Handler(Connection conn, UxRpcMessage msg)
{
var agentId = TryReadFleeTarget(msg);
var template = LookupEnemyTemplate(conn.Session, agentId);
var ours = IsServerSpawnedUid(agentId);
conn.Log.Info($"[TOILET] ostrich-flee agent={agentId} template={(template == 0 ? "?" : template.ToString())}" +
$" isOstrich={IsToiletOstrichAgent(template)} serverSpawned={ours} retire={ToiletCfg.RetireOnFlee}");
if (!ToiletCfg.Enabled || agentId == 0)
return;
// ⛑ 第一步永远是「解除骑乘」,与要不要销毁实体无关 —— 客户端的骑乘态是本地维护的,
// 服务端不推一条 enter=false(外加撤掉 345/346),它就是用户看到的「人还坐在空中」。
var rider = CurrentRiderUnit(conn.Session);
if (ToiletCfg.PushRideState && rider != 0)
await PublishToiletRideAsync(conn, rider, agentId, false);
// ⛑⛑ 默认**不再销毁**坐骑(2026-09-30 19:2x,对齐 v0.5-2 捐赠端):
// 捐赠端原注释 "retaining the test mount for retry instead of destroying it"
// ⇒ 失败只是下马,马桶留在原地可以再来一次。想销毁就开 retireOnFlee。
if (!ToiletCfg.RetireOnFlee)
return;
// ⛑⛑ 硬门槛:只回收本服生成的单位。场景自带单位(uid 落在 spawn 池之外)一律不碰
// (2026-09-30 实测把 uid=19000 的场景敌人删掉了,见 IsServerSpawnedUid 的注释)。
if (!ours)
{
conn.Log.Info($"[TOILET] flee ignored: agent={agentId} is not server-spawned -> scene unit left untouched");
return;
}
// ⛑ SyncLogicAgentLeave(68281244) 是 SyncLogicAgentEnter(68281243) 的配对,也正是
// SpawnAgentAsync 发的第一个包 ⇒ 它就是这条生成通道的移除通道(同 GameRouter.EnemyTerritory.cs)。
// ⛑ 顺手把敌人账本里的条目也清掉:面板生成时若带了 engage,条目留着会让攻击 AI 去驱动一只
// 已经不存在的 unit,死亡/虚弱定时器也会打进空气里。
await conn.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(agentId));
ForgetTerritoryEnemy(conn.Session, agentId);
conn.Log.Info($"[TOILET] retired ostrich unit={agentId}");
}
// ------------------------------------------------------------------ helpers
/// <summary>
/// 当前「骑的人」是谁 —— 也就是 <c>SyncRideAgent</c> 的 <c>rider</c>。
/// 出场单位优先(<c>WorldEntryControlUnit</c>),没有就退回当前操控体
/// (<c>ActiveSpiritUnitId</c>,切人后它会跟着变)。拿不到返回 0(日志里就是 0,此时不推状态包)。
/// </summary>
private static ulong CurrentRiderUnit(TcpSession session)
{
var state = GetStateIfExists(session);
if (state is null)
return 0UL;
return state.WorldEntryControlUnit != 0
? state.WorldEntryControlUnit
: state.ActiveSpiritUnitId;
}
/// <summary>
/// 读出 <c>AskRideAgent</c> 里的单位 id。
/// ⛑ <c>AskStopRideAgent</c> 没有载荷,别拿它来调这个方法。
/// 解析失败不能抛:这是客户端**上报**类请求,服务端炸了会把整帧链路带断(规则 168 的同族问题)。
/// </summary>
private static ulong TryReadRideTarget(UxRpcMessage msg)
{
try
{
return msg.TryGetArgs<GameMethods.AskRideAgent4229938>(out var args) && args is not null
? args.agentId
: 0UL;
}
catch
{
return 0UL;
}
}
/// <summary>
/// 读出 <c>AskToiletOstrichFlee</c> 里的单位 id。
/// ⛑ 两条载荷在线上字节相同(都是单个 u64),但**各用各的契约** —— 互相借会在将来某一方
/// 加字段时静默解析错位,而这里正好是「客户端上报」路径,错了只会静默丢事件。
/// </summary>
private static ulong TryReadFleeTarget(UxRpcMessage msg)
{
try
{
return msg.TryGetArgs<GameMethods.AskToiletOstrichFlee4229938>(out var args) && args is not null
? args.agentId
: 0UL;
}
catch
{
return 0UL;
}
}
/// <summary>
/// uid → 模板 id。先查马桶鸵鸟登记表(坐骑模式不 engage ⇒ 不进敌人账本),再查敌人账本,
/// 都没有返回 0(日志里打成 <c>?</c>,那是正常的,不代表解析失败)。
/// </summary>
private static uint LookupEnemyTemplate(TcpSession session, ulong uid)
{
if (uid == 0)
return 0;
var mounts = ToiletMounts(session);
lock (mounts)
{
if (mounts.TryGetValue(uid, out var mounted))
return mounted;
}
var runtime = GetLocalEnemyRuntimeState(session);
lock (runtime.SyncRoot)
return runtime.Enemies.TryGetValue(uid, out var record) ? record.EnemyId : 0;
}
}