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);
}
}