Files

463 lines
22 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 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>) &gt; <b>角色级</b> <c>rosterNpc.poiOverrides</c>
/// (按 spiritId) &gt; 调用方给的默认值。
///
/// ⛑⛑ 为什么「配了 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&lt;uint, NpcTimeTableInfo&gt;</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&lt;Dictionary&lt;uint, FavorNpcPosInfo&gt;&gt;</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);
}
}