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; /// /// ★★ 生活日程的正式通道 —— SyncFavorNpcSpoonAgentId(64003213)。 /// /// 背景(09-25 实机):里希按日程站在跑步机旁边 / 上面,但只会站着玩手机。 /// 在出场包 AgentSyncClientInfo.AgentDataSetsActivityCfgId 里填活动 id 不够 /// —— 那个字段只是「这个 NPC 归哪条活动管」,客户端还需要知道 /// 「他此刻占着几号 spoon 位、站哪个点、朝哪边」才会去播那条活动的表演。 /// /// 取证来源(补丁和工具\DUMPS\readable_world_dump_4229938_final.zip): /// /// 06_RUNTIME_CODE/.../Auto.Client/UX/Game/IGameToClient.cs: /// void SyncFavorNpcSpoonAgentId(uint agentTag, int spoonAgentId, UXVector3 Position, /// FavorNpcBusyInfo busyInfo = null); /// .../LX6/Units/Module/LifeScheduleNpc/LifeScheduleNpcModule.cs: /// AttachNet(net, activityId, storySpoonAgentId, hasAmbientLoopPose, ambientLoopPos, /// ambientLoopFacing) + SetAmbientLoopPoseContext / /// TryGetStoryLoopPose / BeginAmbientEnterAnchoredPlay /// ⇒ 表演的「入场动画」是从 loop pose 反推锚点后播的。 /// .../L50/StoryClient/Story/World/S009/SLifeScheduleNpcNet.cs(Spoon 的 /// 「日程表NpcNet(Client S树)」):复制的字段就是 /// NpcId / NpcName / ActivityId / AgentTag / SpoonAgentId / HasAmbientLoopPose / /// AmbientLoopPos / AmbientLoopFacing ⇒ 逐项对上了上面那条 RPC。 /// .../Auto.Client/UX/Game/NpcTimeTableInfo.cs: /// CurrentSpoonAgentId + SpoonPosition + 五段 /// NpcScheduleInfo{ActivityId, StartDaySecond, Position, SpoonAgentId, EndDaySecond, /// RaidId} ⇒ 登录帧里那三处我们一直填 0(见 RuntimePayloadFactory)。 /// /// /// ⛑⛑ spoonAgentId 到底该填什么,官方没有配置表(它住在客户端 Spoon 资产里, /// 扫遍 01_CONFIGS / 02_INTERIORS / 03_ZONEGRAPHS / 04_WORLD_JSON 都是零命中)。 /// 目前最自圆其说的取法是「填这个角色自己的 AgentId」—— 因为出场包 /// AgentSyncClientInfo.SpoonAgentId(GameRouter.DonorNpc.cs:234)官方/donor /// 填的就是 NpcFormworkId,两边同一个值客户端才可能对上号。所以默认值 /// agentId,同时留 fixed 手动试号。 /// /// 线格式(RPCDeserializeAuto.lua:1601 的 reader): /// /// u32 agentTag | i32 spoonAgentId | f32 x, f32 y, f32 z | [0x00 = busyInfo 为 null] /// /// ⛑ 基本类型不带存在标记(Plate 那条已实机证过:裸写就能解出来)。 /// internal sealed partial class GameRouter { /// /// 推一条 SyncFavorNpcSpoonAgentId(64003213)。 /// /// 好感 NPC 的 AgentTag(里希 = 12)。 /// spoon 位号。 internal static async Task 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; } /// /// 常驻 NPC 落地后的挂钩:给它补一条「你现在占着几号 spoon 位」。 /// ⛑ 只在它真的有活动(有坐标、有活动 id)且拿到了 AgentTag 时才发, /// 否则等于告诉客户端「去一个不存在的位子」。 /// 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); } /// /// 落地后的 POI 兜底:gameplay.npcLife.poiActionId 非 0 且开关打开时, /// 给刚放出来的单位补一帧 SyncAgentPlayPOIAction。 /// 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); } /// /// ★★ 「这个角色此刻该演什么」的唯一裁决点(09-25 23:0x 加,把里希那一套推广到 /// 所有有日程的角色)。常驻摆放(GameRouter.RosterNpc)与切人留场 /// (GameRouter.Switching)都走这里 —— 两处各写一份必然漂移。 /// /// 优先级:活动级 rosterNpc.activityPoiOverrides (按 /// AgentDataSetsActivityConfig.Id) > 角色级 rosterNpc.poiOverrides /// (按 spiritId) > 调用方给的默认值。 /// /// ⛑⛑ 为什么「配了 POI 就不带活动 id」:生活日程的 ambient 会盖掉 POI /// —— 里希最早就是「一边打电话一边跑步」(规则 163)。 /// 没配映射的活动照旧带活动 id,走生活日程(那是官方那条路,只是没表演)。 /// /// ⛑ 朝向返回 float?:调用方要能区分「没配」(沿用各自原来的朝向) /// 和「配了 0」。常驻原来恒 0,切人留场原来用玩家切走时的朝向。 /// 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); } /// /// ★★ 落地坐标的**活动级微调**(gameplay.rosterNpc.activityPoiOverrides[].offsetX/Y/Z)。 /// /// 官方 MapPosition 是策划打的点,跟场景家具常常差几十厘米;这种偏差没有表可查, /// 只能实机挪。字段语义(世界轴 / 朝向 0 时右 = +X)见 /// 。 /// /// ⛑ 三个调用点必须**都**走这里(常驻摆放 / 切人留场 / 主动切到日程点), /// 否则同一个角色会因为出场方式不同而站在两个不同的地方。 /// ⛑ 没配 / 查不到 ⇒ 原样返回,零开销、零行为变化。 /// 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); } /// 按 决定 spoon 位号。 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) // ================================================================================= /// /// ★★ spoon 帧的硬前提:客户端 NpcDaliyManager.lua:143 /// OnSyncFavorNpcSpoonAgentId 一进门就判 /// table.isNilOrEmpty(self.NpcTimeTableInfos[agentTag]),空则 return /// ⇒ 没有时刻表时 64003213 整帧被静默丢弃(不报错、不打日志)。 /// 09-25 19:36 第一次实机推 spoon「零反应」就是这个原因。 /// /// 线格式(RPCDeserializeAuto.lua:2327 / :3003): /// /// 64219939: FF + Int7(count) + 每项 [u32 agentTag] + [Complex NpcTimeTableInfo] /// 64400736: u32 agentTag + [Complex NpcTimeTableInfo] /// /// ⛑ NpcTimeTableInfo 本体交给 UxSerializer —— 它和登录帧 /// FavorNpcDailyScheduleInfos 用的是同一套(Complex,TypeMark 0xFF, /// Position/SpoonPosition 是 Struct 不带标记,TempSchedule 可空), /// 别手搓字节。只有最外层字典要自己写:UxSerializer.Serialize(Dictionary) /// 顶层没有 field ⇒ count 会按 Int32 写,而客户端这里读的是 ReadDict7Bit。 /// internal static async Task 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; } /// /// 造一份 Dictionary<uint, NpcTimeTableInfo>。与登录帧同源(NpcDailySchedule.json /// 的五段 × NpcActivityCatalog.json 的 MapPosition),区别是 /// 那一条会把 CurrentSpoonAgentId / /// SpoonPosition(以及当前时段的 SpoonAgentId)填上真值 —— 登录帧那三处恒为 0。 /// internal static Dictionary BuildTimeTables( uint focusAgentTag, int spoonAgentId, NpcLifeSettings cfg) { var tables = new Dictionary(); 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) { // 五段里哪一段是「现在」由客户端自己判,这里只把每一段都填上更省事? // ⛑ 不 —— 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; } /// 当前时段落在五段里的第几段(找不到就 -1)。与 NpcActivityCatalog.CurrentFor 同判据。 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; } /// spoon 坐标 = 当前时段那条活动的 MapPosition(查不到就是零向量)。 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) // ================================================================================= /// /// 让某个单位直接播一个 POI 动作(UrbanDiversityPOIActionConfig)。 /// 通道是已验证过的原生帧(GameRouter.Ambient.cs:892 同款): /// 跑步机 = 198「站姿_运动_跑步机跑步」(MainActionGroup 400 / MainActions [86190])。 /// ⛑ 开了生活日程(AgentDataSetsActivityCfgId 非 0)时客户端会盖掉它 /// ⇒ 要验这条就把 gameplay.rosterNpc.dailyLife 关掉。 /// internal static async Task 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 都在哪儿」 // ================================================================================= /// /// C2S AskQueryAllFavorNpcAgentPos(63357395) / AskQueryFavorNpcAgentPos(63216726)。 /// /// ★★ 为什么这条比上面那条还重要:**客户端自己在问**,而 `unknown-methods.log` 里 /// 63357395 已经出现过 54 次 —— 我们每次都回「空字典」(`DefaultReturnCatalog4229938` /// 的 `dict7-empty` = `FF 01`)⇒ 客户端眼里「城里一个好感 NPC 都不在场」, /// 那 `LifeScheduleNpcModule` 自然不会给任何人排表演。 /// /// 返回类型(客户端 C# ClientToGameDelegate): /// UXRPCTask<Dictionary<uint, FavorNpcPosInfo>>, /// FavorNpcPosInfo { uint RaidId; UXVector3 Position; }(key = AgentTag)。 /// /// 线格式:`FF` + Int7(count+1) + 每项 [u32 agentTag] + [FF + u32 RaidId + f32×3]。 /// ⛑ count 用 UxWriter.Int7(内部已 +1),别自己再加。 /// [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); } }