366 lines
20 KiB
C#
366 lines
20 KiB
C#
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;
|
||
}
|
||
}
|