463 lines
22 KiB
C#
463 lines
22 KiB
C#
using System.Buffers.Binary;
|
||
using System.Linq;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
|
||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// ★★ 生活日程的<b>正式通道</b> —— <c>SyncFavorNpcSpoonAgentId</c>(64003213)。
|
||
///
|
||
/// 背景(09-25 实机):里希按日程站在跑步机旁边 / 上面,但只会站着玩手机。
|
||
/// 在出场包 <c>AgentSyncClientInfo.AgentDataSetsActivityCfgId</c> 里填活动 id <b>不够</b>
|
||
/// —— 那个字段只是「这个 NPC 归哪条活动管」,客户端还需要知道
|
||
/// 「他此刻占着几号 spoon 位、站哪个点、朝哪边」才会去播那条活动的表演。
|
||
///
|
||
/// 取证来源(<c>补丁和工具\DUMPS\readable_world_dump_4229938_final.zip</c>):
|
||
/// <list type="bullet">
|
||
/// <item><c>06_RUNTIME_CODE/.../Auto.Client/UX/Game/IGameToClient.cs</c>:
|
||
/// <c>void SyncFavorNpcSpoonAgentId(uint agentTag, int spoonAgentId, UXVector3 Position,
|
||
/// FavorNpcBusyInfo busyInfo = null);</c></item>
|
||
/// <item><c>.../LX6/Units/Module/LifeScheduleNpc/LifeScheduleNpcModule.cs</c>:
|
||
/// <c>AttachNet(net, activityId, storySpoonAgentId, hasAmbientLoopPose, ambientLoopPos,
|
||
/// ambientLoopFacing)</c> + <c>SetAmbientLoopPoseContext</c> /
|
||
/// <c>TryGetStoryLoopPose</c> / <c>BeginAmbientEnterAnchoredPlay</c>
|
||
/// ⇒ 表演的「入场动画」是从 loop pose 反推锚点后播的。</item>
|
||
/// <item><c>.../L50/StoryClient/Story/World/S009/SLifeScheduleNpcNet.cs</c>(Spoon 的
|
||
/// 「日程表NpcNet(Client S树)」):复制的字段就是
|
||
/// <c>NpcId / NpcName / ActivityId / AgentTag / SpoonAgentId / HasAmbientLoopPose /
|
||
/// AmbientLoopPos / AmbientLoopFacing</c> ⇒ 逐项对上了上面那条 RPC。</item>
|
||
/// <item><c>.../Auto.Client/UX/Game/NpcTimeTableInfo.cs</c>:
|
||
/// <c>CurrentSpoonAgentId</c> + <c>SpoonPosition</c> + 五段
|
||
/// <c>NpcScheduleInfo{ActivityId, StartDaySecond, Position, SpoonAgentId, EndDaySecond,
|
||
/// RaidId}</c> ⇒ 登录帧里那三处我们一直填 0(见 <c>RuntimePayloadFactory</c>)。</item>
|
||
/// </list>
|
||
///
|
||
/// ⛑⛑ <b>spoonAgentId 到底该填什么,官方没有配置表</b>(它住在客户端 Spoon 资产里,
|
||
/// 扫遍 01_CONFIGS / 02_INTERIORS / 03_ZONEGRAPHS / 04_WORLD_JSON 都是零命中)。
|
||
/// 目前最自圆其说的取法是「填这个角色自己的 AgentId」—— 因为出场包
|
||
/// <c>AgentSyncClientInfo.SpoonAgentId</c>(<c>GameRouter.DonorNpc.cs:234</c>)官方/donor
|
||
/// 填的就是 <c>NpcFormworkId</c>,两边同一个值客户端才可能对上号。所以默认值
|
||
/// <c>agentId</c>,同时留 <c>fixed</c> 手动试号。
|
||
///
|
||
/// 线格式(<c>RPCDeserializeAuto.lua:1601</c> 的 reader):
|
||
/// <code>
|
||
/// u32 agentTag | i32 spoonAgentId | f32 x, f32 y, f32 z | [0x00 = busyInfo 为 null]
|
||
/// </code>
|
||
/// ⛑ 基本类型<b>不带存在标记</b>(Plate 那条已实机证过:裸写就能解出来)。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>
|
||
/// 推一条 <c>SyncFavorNpcSpoonAgentId</c>(64003213)。
|
||
/// </summary>
|
||
/// <param name="agentTag">好感 NPC 的 AgentTag(里希 = 12)。</param>
|
||
/// <param name="spoonAgentId">spoon 位号。</param>
|
||
internal static async Task<int> PushNpcSpoonAgentAsync(
|
||
TcpSession session, uint agentTag, int spoonAgentId,
|
||
float x, float y, float z, NpcLifeSettings cfg)
|
||
{
|
||
var w = new UxWriter();
|
||
w.U32(agentTag);
|
||
w.I32(spoonAgentId);
|
||
w.F32(x);
|
||
w.F32(y);
|
||
w.F32(z);
|
||
if (cfg.BusyInfoMarkerByte >= 0)
|
||
w.U8((byte)cfg.BusyInfoMarkerByte); // FavorNpcBusyInfo busyInfo = null
|
||
|
||
var body = w.ToArray();
|
||
await session.NotifyAsync(MethodId.SyncFavorNpcSpoonAgentId, body, CancellationToken.None);
|
||
|
||
session.Log.Info($"[NPC-LIFE] SyncFavorNpcSpoonAgentId agentTag={agentTag} " +
|
||
$"spoon={spoonAgentId} at=({x:0.##},{y:0.##},{z:0.##}) " +
|
||
$"bytes={body.Length} hex={Convert.ToHexString(body)}");
|
||
return body.Length;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 常驻 NPC 落地后的挂钩:给它补一条「你现在占着几号 spoon 位」。
|
||
/// ⛑ 只在它真的有活动(有坐标、有活动 id)且拿到了 AgentTag 时才发,
|
||
/// 否则等于告诉客户端「去一个不存在的位子」。
|
||
/// </summary>
|
||
internal static async Task EnsureNpcSpoonAsync(
|
||
TcpSession session, uint spiritId, uint agentId, uint activityId, float x, float y, float z)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.NpcLife;
|
||
if (!cfg.Enabled || !cfg.PushOnPlace || activityId == 0)
|
||
return;
|
||
|
||
var agentTag = NpcCultivationCatalog4229938.AgentTagForSpirit(spiritId);
|
||
if (agentTag == 0)
|
||
return;
|
||
|
||
var spoon = ResolveSpoonAgentId(cfg, agentId);
|
||
|
||
// ⛑⛑ 顺序不能反:先灌时刻表,再推 spoon。没有时刻表时客户端
|
||
// OnSyncFavorNpcSpoonAgentId 第一行就 return,spoon 帧等于白发。
|
||
if (cfg.PushTimeTable)
|
||
await PushNpcTimeTableAsync(session, agentTag, spoon, cfg);
|
||
|
||
await PushNpcSpoonAgentAsync(session, agentTag, spoon, x, y, z, cfg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 落地后的 POI 兜底:<c>gameplay.npcLife.poiActionId</c> 非 0 且开关打开时,
|
||
/// 给刚放出来的单位补一帧 <c>SyncAgentPlayPOIAction</c>。
|
||
/// </summary>
|
||
internal static async Task EnsureNpcPoiActionAsync(TcpSession session, ulong unitId)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.NpcLife;
|
||
if (!cfg.Enabled || !cfg.PushPoiOnPlace || cfg.PoiActionId == 0)
|
||
return;
|
||
await PushNpcPoiActionAsync(session, unitId, cfg.PoiActionId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★ 「这个角色此刻该演什么」的<b>唯一裁决点</b>(09-25 23:0x 加,把里希那一套推广到
|
||
/// 所有有日程的角色)。常驻摆放(<c>GameRouter.RosterNpc</c>)与切人留场
|
||
/// (<c>GameRouter.Switching</c>)都走这里 —— 两处各写一份必然漂移。
|
||
///
|
||
/// 优先级:<b>活动级</b> <c>rosterNpc.activityPoiOverrides</c> (按
|
||
/// <c>AgentDataSetsActivityConfig.Id</c>) > <b>角色级</b> <c>rosterNpc.poiOverrides</c>
|
||
/// (按 spiritId) > 调用方给的默认值。
|
||
///
|
||
/// ⛑⛑ 为什么「配了 POI 就不带活动 id」:生活日程的 ambient 会<b>盖掉</b> POI
|
||
/// —— 里希最早就是「一边打电话一边跑步」(规则 163)。
|
||
/// 没配映射的活动照旧带活动 id,走生活日程(那是官方那条路,只是没表演)。
|
||
///
|
||
/// ⛑ 朝向返回 <c>float?</c>:调用方要能区分「没配」(沿用各自原来的朝向)
|
||
/// 和「配了 0」。常驻原来恒 0,切人留场原来用玩家切走时的朝向。
|
||
/// </summary>
|
||
internal static (uint PoiActionId, uint ActivityCfgId, float? Facing, bool SkipPhone)
|
||
ResolveActivityPresentation(uint spiritId, uint activityId, uint defaultPoiActionId)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.RosterNpc;
|
||
|
||
var actOv = activityId != 0
|
||
? cfg.ActivityPoiOverrides.FirstOrDefault(o => o.ActivityId == activityId && o.PoiActionId != 0)
|
||
: null;
|
||
var spiritOv = spiritId != 0
|
||
? cfg.PoiOverrides.FirstOrDefault(o => o.SpiritId == spiritId)
|
||
: null;
|
||
|
||
var poi = actOv?.PoiActionId ?? spiritOv?.PoiActionId ?? defaultPoiActionId;
|
||
var facing = actOv?.Facing ?? spiritOv?.Facing;
|
||
var skipPhone = actOv?.SkipPhone ?? spiritOv?.SkipPhone ?? false;
|
||
|
||
var sendActivity = actOv is not null
|
||
? actOv.SendActivityCfgId ?? false
|
||
: spiritOv?.SendActivityCfgId ?? true;
|
||
|
||
return (poi, sendActivity ? activityId : 0u, facing, skipPhone);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★ 落地坐标的**活动级微调**(<c>gameplay.rosterNpc.activityPoiOverrides[].offsetX/Y/Z</c>)。
|
||
///
|
||
/// 官方 MapPosition 是策划打的点,跟场景家具常常差几十厘米;这种偏差没有表可查,
|
||
/// 只能实机挪。字段语义(世界轴 / 朝向 0 时右 = +X)见
|
||
/// <see cref="ActivityPoiOverride.OffsetX"/>。
|
||
///
|
||
/// ⛑ 三个调用点必须**都**走这里(常驻摆放 / 切人留场 / 主动切到日程点),
|
||
/// 否则同一个角色会因为出场方式不同而站在两个不同的地方。
|
||
/// ⛑ 没配 / 查不到 ⇒ 原样返回,零开销、零行为变化。
|
||
/// </summary>
|
||
internal static (float X, float Y, float Z) ApplyActivityOffset(
|
||
uint activityId, float x, float y, float z)
|
||
{
|
||
if (activityId == 0)
|
||
return (x, y, z);
|
||
|
||
if (activityId == 0)
|
||
return (x, y, z);
|
||
|
||
// ⛑ 只认「这条活动真的配了偏移」的那一项;查不到 / 三个都是 0 ⇒ 原样返回。
|
||
foreach (var ov in PrivateServerConfigStore.Current.Gameplay.RosterNpc.ActivityPoiOverrides)
|
||
{
|
||
if (ov.ActivityId != activityId)
|
||
continue;
|
||
var dx = ov.OffsetX ?? 0f;
|
||
var dy = ov.OffsetY ?? 0f;
|
||
var dz = ov.OffsetZ ?? 0f;
|
||
if (dx == 0f && dy == 0f && dz == 0f)
|
||
continue;
|
||
return (x + dx, y + dy, z + dz);
|
||
}
|
||
return (x, y, z);
|
||
}
|
||
|
||
/// <summary>按 <see cref="NpcLifeSettings.SpoonAgentIdMode"/> 决定 spoon 位号。</summary>
|
||
internal static int ResolveSpoonAgentId(NpcLifeSettings cfg, uint agentId)
|
||
{
|
||
var mode = (cfg.SpoonAgentIdMode ?? "").Trim().ToLowerInvariant();
|
||
return mode switch
|
||
{
|
||
"fixed" => cfg.SpoonAgentIdFixed,
|
||
"agentid" => (int)agentId,
|
||
_ => 0,
|
||
};
|
||
}
|
||
|
||
// =================================================================================
|
||
// ① 时刻表 SyncFavorNpcTimeTableInfos(64219939) / SyncAgentTagTimeTableInfo(64400736)
|
||
// =================================================================================
|
||
|
||
/// <summary>
|
||
/// ★★ <b>spoon 帧的硬前提</b>:客户端 <c>NpcDaliyManager.lua:143</c>
|
||
/// <c>OnSyncFavorNpcSpoonAgentId</c> 一进门就判
|
||
/// <c>table.isNilOrEmpty(self.NpcTimeTableInfos[agentTag])</c>,空则 <c>return</c>
|
||
/// ⇒ 没有时刻表时 64003213 <b>整帧被静默丢弃</b>(不报错、不打日志)。
|
||
/// 09-25 19:36 第一次实机推 spoon「零反应」就是这个原因。
|
||
///
|
||
/// 线格式(<c>RPCDeserializeAuto.lua:2327</c> / <c>:3003</c>):
|
||
/// <code>
|
||
/// 64219939: FF + Int7(count) + 每项 [u32 agentTag] + [Complex NpcTimeTableInfo]
|
||
/// 64400736: u32 agentTag + [Complex NpcTimeTableInfo]
|
||
/// </code>
|
||
/// ⛑ <c>NpcTimeTableInfo</c> 本体交给 <c>UxSerializer</c> —— 它和登录帧
|
||
/// <c>FavorNpcDailyScheduleInfos</c> 用的是同一套(Complex,TypeMark 0xFF,
|
||
/// <c>Position</c>/<c>SpoonPosition</c> 是 Struct 不带标记,<c>TempSchedule</c> 可空),
|
||
/// 别手搓字节。只有<b>最外层字典</b>要自己写:<c>UxSerializer.Serialize(Dictionary)</c>
|
||
/// 顶层没有 field ⇒ count 会按 <c>Int32</c> 写,而客户端这里读的是 <c>ReadDict7Bit</c>。
|
||
/// </summary>
|
||
internal static async Task<int> PushNpcTimeTableAsync(
|
||
TcpSession session, uint focusAgentTag, int spoonAgentId, NpcLifeSettings cfg)
|
||
{
|
||
var tables = BuildTimeTables(focusAgentTag, spoonAgentId, cfg);
|
||
if (tables.Count == 0)
|
||
return 0;
|
||
|
||
if (cfg.TimeTableSingleTag)
|
||
{
|
||
// 64400736:(uint agentTag, NpcTimeTableInfo) —— 包体最小,用来先排掉字典线格式的变量。
|
||
var tag = focusAgentTag != 0 && tables.ContainsKey(focusAgentTag)
|
||
? focusAgentTag
|
||
: tables.Keys.First();
|
||
var single = new UxWriter();
|
||
single.U32(tag);
|
||
single.Raw(UxSerializer.Serialize(tables[tag]));
|
||
var body1 = single.ToArray();
|
||
await session.NotifyAsync(MethodId.IGameToClient_SyncAgentTagTimeTableInfo, body1, CancellationToken.None);
|
||
session.Log.Info($"[NPC-LIFE] SyncAgentTagTimeTableInfo agentTag={tag} " +
|
||
$"spoon={spoonAgentId} bytes={body1.Length} " +
|
||
$"hex={(body1.Length > 64 ? Convert.ToHexString(body1, 0, 64) + "…" : Convert.ToHexString(body1))}");
|
||
return body1.Length;
|
||
}
|
||
|
||
var w = new UxWriter();
|
||
w.U8(0xFF); // 字典存在标记
|
||
w.Int7(tables.Count); // ⛑ Int7 内部写 count+1(客户端 ReadDict7Bit)
|
||
foreach (var kv in tables)
|
||
{
|
||
w.U32(kv.Key);
|
||
w.Raw(UxSerializer.Serialize(kv.Value));
|
||
}
|
||
var body = w.ToArray();
|
||
await session.NotifyAsync(MethodId.SyncFavorNpcTimeTableInfos, body, CancellationToken.None);
|
||
|
||
session.Log.Info($"[NPC-LIFE] SyncFavorNpcTimeTableInfos tags={tables.Count} " +
|
||
$"focus={focusAgentTag} spoon={spoonAgentId} bytes={body.Length} " +
|
||
$"hex={(body.Length > 64 ? Convert.ToHexString(body, 0, 64) + "…" : Convert.ToHexString(body))}");
|
||
return body.Length;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 造一份 <c>Dictionary<uint, NpcTimeTableInfo></c>。与登录帧同源(<c>NpcDailySchedule.json</c>
|
||
/// 的五段 × <c>NpcActivityCatalog.json</c> 的 MapPosition),区别是
|
||
/// <paramref name="focusAgentTag"/> 那一条会把 <c>CurrentSpoonAgentId</c> /
|
||
/// <c>SpoonPosition</c>(以及当前时段的 <c>SpoonAgentId</c>)填上真值 —— 登录帧那三处恒为 0。
|
||
/// </summary>
|
||
internal static Dictionary<uint, Auto.NpcTimeTableInfo> BuildTimeTables(
|
||
uint focusAgentTag, int spoonAgentId, NpcLifeSettings cfg)
|
||
{
|
||
var tables = new Dictionary<uint, Auto.NpcTimeTableInfo>();
|
||
var todayStart = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero).ToUnixTimeSeconds();
|
||
|
||
static Auto.NpcScheduleInfo Slice(NpcScheduleSlice4229938 s) => new()
|
||
{
|
||
ActivityId = s.ActivityId,
|
||
StartDaySecond = s.StartDaySecond,
|
||
Position = NpcActivityCatalog4229938.TryGet(s.ActivityId, out var at)
|
||
? new Auto.UXVector3 { X = at.X, Y = at.Y, Z = at.Z }
|
||
: new Auto.UXVector3(),
|
||
SpoonAgentId = 0,
|
||
EndDaySecond = s.EndDaySecond,
|
||
RaidId = s.RaidId,
|
||
};
|
||
|
||
foreach (var entry in NpcScheduleCatalog4229938.Entries)
|
||
{
|
||
var table = new Auto.NpcTimeTableInfo
|
||
{
|
||
Schedule0 = Slice(entry.Slices[0]),
|
||
Schedule1 = Slice(entry.Slices[1]),
|
||
Schedule2 = Slice(entry.Slices[2]),
|
||
Schedule3 = Slice(entry.Slices[3]),
|
||
Schedule4 = Slice(entry.Slices[4]),
|
||
CurrentSpoonAgentId = 0,
|
||
SpoonPosition = new Auto.UXVector3(),
|
||
TempSchedule = null,
|
||
IsTempScheduleOnly = false,
|
||
RefreshDay = todayStart,
|
||
};
|
||
tables[entry.AgentTag] = table;
|
||
}
|
||
|
||
if (focusAgentTag == 0 || !tables.TryGetValue(focusAgentTag, out var focus) || spoonAgentId == 0)
|
||
return tables;
|
||
|
||
focus.CurrentSpoonAgentId = spoonAgentId;
|
||
focus.SpoonPosition = SpoonPositionFor(focusAgentTag);
|
||
|
||
if (cfg.TimeTableSpoonInSlice)
|
||
{
|
||
// 五段里哪一段是「现在」由客户端自己判,这里只把<b>每一段都填上</b>更省事?
|
||
// ⛑ 不 —— SpoonAgentId 是「这个时段占哪个坑」,全填同一个值会让客户端以为
|
||
// 五个时段都占同一台器材。挑当前时段那一段填,其余留 0。
|
||
var daySecond = (int)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() % 86400);
|
||
var idx = CurrentSliceIndex(focusAgentTag, daySecond);
|
||
var target = idx switch
|
||
{
|
||
0 => focus.Schedule0,
|
||
1 => focus.Schedule1,
|
||
2 => focus.Schedule2,
|
||
3 => focus.Schedule3,
|
||
4 => focus.Schedule4,
|
||
_ => null,
|
||
};
|
||
if (target is not null)
|
||
target.SpoonAgentId = spoonAgentId;
|
||
}
|
||
|
||
return tables;
|
||
}
|
||
|
||
/// <summary>当前时段落在五段里的第几段(找不到就 -1)。与 <c>NpcActivityCatalog.CurrentFor</c> 同判据。</summary>
|
||
private static int CurrentSliceIndex(uint agentTag, int daySecond)
|
||
{
|
||
var slices = NpcScheduleCatalog4229938.For(agentTag);
|
||
for (var i = 0; i < slices.Count && i < 5; i++)
|
||
{
|
||
var s = slices[i];
|
||
if (s.StartDaySecond <= daySecond && daySecond < s.EndDaySecond)
|
||
return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/// <summary>spoon 坐标 = 当前时段那条活动的 MapPosition(查不到就是零向量)。</summary>
|
||
private static Auto.UXVector3 SpoonPositionFor(uint agentTag)
|
||
{
|
||
var daySecond = (int)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() % 86400);
|
||
var e = NpcActivityCatalog4229938.CurrentFor(agentTag, daySecond);
|
||
if (e is null || !e.HasPosition)
|
||
e = NpcActivityCatalog4229938.FirstPositioned(agentTag, 0);
|
||
return e is null
|
||
? new Auto.UXVector3()
|
||
: new Auto.UXVector3 { X = e.X, Y = e.Y, Z = e.Z };
|
||
}
|
||
|
||
// =================================================================================
|
||
// ① 兜底 POI 动作 SyncAgentPlayPOIAction(68459398)
|
||
// =================================================================================
|
||
|
||
/// <summary>
|
||
/// 让某个单位直接播一个 POI 动作(<c>UrbanDiversityPOIActionConfig</c>)。
|
||
/// 通道是已验证过的原生帧(<c>GameRouter.Ambient.cs:892</c> 同款):
|
||
/// 跑步机 = <b>198「站姿_运动_跑步机跑步」</b>(MainActionGroup 400 / MainActions [86190])。
|
||
/// ⛑ 开了生活日程(<c>AgentDataSetsActivityCfgId</c> 非 0)时客户端会盖掉它
|
||
/// ⇒ 要验这条就把 <c>gameplay.rosterNpc.dailyLife</c> 关掉。
|
||
/// </summary>
|
||
internal static async Task<int> PushNpcPoiActionAsync(TcpSession session, ulong unitId, uint poiActionId)
|
||
{
|
||
if (unitId == 0 || poiActionId == 0)
|
||
return 0;
|
||
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
|
||
UxSerializer.Serialize(new GameMethods.DonorClientNpcPoiActionData4229938
|
||
{
|
||
Id = unitId,
|
||
SPoiActionId = 0,
|
||
CPoiActionId = poiActionId,
|
||
IsActive = true,
|
||
PlayPoiSpeed = 1f,
|
||
}), CancellationToken.None);
|
||
|
||
session.Log.Info($"[NPC-LIFE] SyncAgentPlayPOIAction unit={unitId} poi={poiActionId}");
|
||
return 1;
|
||
}
|
||
|
||
// =================================================================================
|
||
// ② C2S 查询:客户端主动问「好感 NPC 都在哪儿」
|
||
// =================================================================================
|
||
|
||
/// <summary>
|
||
/// C2S <c>AskQueryAllFavorNpcAgentPos</c>(63357395) / <c>AskQueryFavorNpcAgentPos</c>(63216726)。
|
||
///
|
||
/// ★★ 为什么这条比上面那条还重要:**客户端自己在问**,而 `unknown-methods.log` 里
|
||
/// 63357395 已经出现过 54 次 —— 我们每次都回「空字典」(`DefaultReturnCatalog4229938`
|
||
/// 的 `dict7-empty` = `FF 01`)⇒ 客户端眼里「城里一个好感 NPC 都不在场」,
|
||
/// 那 `LifeScheduleNpcModule` 自然不会给任何人排表演。
|
||
///
|
||
/// 返回类型(客户端 C# <c>ClientToGameDelegate</c>):
|
||
/// <c>UXRPCTask<Dictionary<uint, FavorNpcPosInfo>></c>,
|
||
/// <c>FavorNpcPosInfo { uint RaidId; UXVector3 Position; }</c>(key = AgentTag)。
|
||
///
|
||
/// 线格式:`FF` + Int7(count+1) + 每项 [u32 agentTag] + [FF + u32 RaidId + f32×3]。
|
||
/// ⛑ count 用 <c>UxWriter.Int7</c>(内部已 +1),别自己再加。
|
||
/// </summary>
|
||
[Handler(MethodId.AskQueryAllFavorNpcAgentPos, HandlerPacketKind.Invoke)]
|
||
[Handler(MethodId.AskQueryFavorNpcAgentPos, HandlerPacketKind.Invoke)]
|
||
private Task OnAskQueryFavorNpcAgentPos(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var all = msg.MethodId == MethodId.AskQueryAllFavorNpcAgentPos;
|
||
uint only = 0;
|
||
if (!all && msg.Body.Length >= 4)
|
||
only = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body);
|
||
|
||
var state = GetStateIfExists(conn.Session);
|
||
var raid = state?.ActiveRaidId ?? Profile.RaidId;
|
||
var daySecond = state?.CurrentDaySecond() ?? 0;
|
||
|
||
// 数据 = 与常驻 NPC 落点同一份:当前时段的活动坐标,查不到就退到本场景里第一条有坐标的。
|
||
var rows = new List<(uint Tag, uint Raid, float X, float Y, float Z)>();
|
||
foreach (var entry in NpcScheduleCatalog4229938.Entries)
|
||
{
|
||
if (all == false && entry.AgentTag != only)
|
||
continue;
|
||
var e = NpcActivityCatalog4229938.CurrentFor(entry.AgentTag, daySecond);
|
||
if (e is not null && (!e.HasPosition || (raid != 0 && e.Raid != 0 && e.Raid != raid)))
|
||
e = null;
|
||
e ??= NpcActivityCatalog4229938.FirstPositioned(entry.AgentTag, raid);
|
||
if (e is null || !e.HasPosition)
|
||
continue;
|
||
rows.Add((entry.AgentTag, e.Raid, e.X, e.Y, e.Z));
|
||
}
|
||
|
||
var w = new UxWriter();
|
||
w.U8(0xFF); // 字典存在标记
|
||
w.Int7(rows.Count); // ⛑ Int7 内部写 count+1
|
||
foreach (var r in rows)
|
||
{
|
||
w.U32(r.Tag);
|
||
w.U8(0xFF); // FavorNpcPosInfo 存在标记
|
||
w.U32(r.Raid);
|
||
w.F32(r.X); w.F32(r.Y); w.F32(r.Z);
|
||
}
|
||
var body = w.ToArray();
|
||
|
||
conn.Log.Info($"[NPC-LIFE] AskQuery{(all ? "All" : "")}FavorNpcAgentPos " +
|
||
$"only={only} raid={raid} daySecond={daySecond} -> {rows.Count} 条 " +
|
||
$"bytes={body.Length} hex={(body.Length > 48 ? Convert.ToHexString(body, 0, 48) : Convert.ToHexString(body))}");
|
||
|
||
return msg.Context.ReturnAsync(body);
|
||
}
|
||
}
|