378 lines
19 KiB
C#
378 lines
19 KiB
C#
using Ananta.SDK.Logging;
|
||
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 GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 房产自带家具的「家具机关(furniture gadget)」—— 2026-10-01。
|
||
///
|
||
/// <para><b>用户报的症状</b>:「沙发椅子已经生成了的没法交互」——
|
||
/// 家具<b>模型</b>都在(<c>AskLoadHouseData</c> 的 <c>FloorBuildInfoDict</c> 已上线),
|
||
/// 但椅子坐不了、衣柜开不了、电视点不亮。</para>
|
||
///
|
||
/// <para><b>根因(一句话)</b>:客户端把家具建出来之后<b>会再问一次服务端</b>要机关实例 id ——
|
||
/// <c>HouseGadgetManager.lua:CreateGadgetsForFurnitures</c> →
|
||
/// <c>AskCreateHouseFurnitureGadgets</c>(63581446),读应答里的
|
||
/// <c>mapping.placedID2GadgetInstanceID</c>,非 0 才写进 <c>placedId2GadgetIdMap</c>
|
||
/// 并 <c>gFurnitureUtils:TrySetGadgetInstanceId(furnitureGo, id)</c>。
|
||
/// 而这条 RPC 在服务端一直是 <c>DefaultReturnCatalog4229938.cs:624</c> 的
|
||
/// <c>body=[0xFF,0x01] shape="struct(631:dict7-empty)"</c> —— <b>恒回空映射</b>
|
||
/// ⇒ <c>placedId2GadgetIdMap</c> 恒空 ⇒ 所有机关失效。日志铁证(<c>console-latest.log:319-321</c>):
|
||
/// <code>
|
||
/// -> rpc AskCreateHouseFurnitureGadgets [0x03CA2D06] 274b
|
||
/// ! [RPC4229938] unimplemented AskCreateHouseFurnitureGadgets -> typed-default struct(631:dict7-empty) 2b
|
||
/// <- ret ... e=0 2b
|
||
/// </code></para>
|
||
///
|
||
/// <para><b>★ 为什么说「家具树本身是对的,只缺这一步」</b>:客户端请求的 id 个数
|
||
/// <b>逐套精确等于</b>该房模板树里「<c>HouseFurnitureConfig.PathId != 0</c>」的家具数 ——
|
||
/// 绿屿 33/33、旧物仓 3/3、观澜 6/6、素光 1/1(<c>unknown-methods.log</c> 请求体逐字节解码后与模板树对齐)。
|
||
/// 而且请求里的 id 正好是 <c>PlacedInstanceId = houseId*100000 + nodeId</c> 的 32 位截断形态
|
||
/// (绿屿首件 <c>0xC5388A93 - 0xC5381F20 = 14003</c> = 模板节点号)。</para>
|
||
///
|
||
/// <para><b>★ 机关 prefab 从哪来</b>:<c>HouseFurnitureConfig.PathId</c>
|
||
/// <b>就是 <c>GadgetCatalog.json</c> 的 pathId</b>(逐条核对过,见
|
||
/// <c>HouseFurnitureGadgetCatalog4229938</c> 的注释表)。位置也不用反推 ——
|
||
/// <c>HouseFurnitureTemplateConfig</c> 节点的 <c>Position</c> <b>就是世界绝对坐标</b>
|
||
/// (绿屿椅 <c>(2290.629, 160.720, 1397.878)</c>、素光衣柜 <c>(1096.527, 13.040, 2375.721)</c>),
|
||
/// <c>Rotation.Y</c> 就是 yaw。</para>
|
||
///
|
||
/// <para><b>本文件做的事</b>:</para>
|
||
/// <list type="bullet">
|
||
/// <item><c>AskCreateHouseFurnitureGadgets</c>:对请求里每个 <c>placedId</c>
|
||
/// 反解 <c>nodeId → FurnitureId → PathId</c>,回 <c>placedId → gadgetInstanceId</c> 映射;
|
||
/// <c>spawnGadgetEntities=true</c>(默认)时<b>同时</b>用 <c>SyncGadgetAdd</c>(68843189)
|
||
/// 把机关实体摆到家具节点的世界坐标上(<c>uniqueId = placedId</c>,与返回值同源)。</item>
|
||
/// <item><c>AskDestoryHouseFurnitureGadgets</c>:摘掉记录 + 推
|
||
/// <c>SyncGadgetRemoveList</c>(68199571),回空 body(客户端该 RPC 的应答 reader 是空函数)。</item>
|
||
/// </list>
|
||
///
|
||
/// <para><b>⛔ 两个开关都要留</b>(<c>gameplay.houseFurniture</c>):
|
||
/// <c>gadgets</c> 是总开关;<c>spawnGadgetEntities</c> 决定要不要真的 <c>SyncGadgetAdd</c>。
|
||
/// 后者是<b>实测分流</b>用的 —— 有两种可能,客户端行为决定哪一种:
|
||
/// <list type="number">
|
||
/// <item><b>H1(默认,spawn=true)</b>:机关实体得由服务端 <c>SyncGadgetAdd</c> 造出来,
|
||
/// 客户端 <c>OnGadgetSlotCompLoaded</c> 的 <c>gGadgetManager:GetEntitySearchByInstanceId(gadgetId)</c> 才找得到。</item>
|
||
/// <item><b>H2(spawn=false)</b>:机关 GameObject 本来就在家具 prefab 里
|
||
/// (<c>RegisterFurnitureLuaSlotReplace</c> 用的就是 <c>houseFurniture.gadgetGameObject</c>),
|
||
/// 服务端只需发一个<b>唯一的非 0 id</b>,客户端自己 <c>TrySetGadgetInstanceId</c> 就能把它点亮。
|
||
/// <c>OnGadgetSlotCompLoaded</c> 里那个 10 帧重试计时器更像 H2(id 要等 <c>TrySetGadgetInstanceId</c> 之后才可寻址)。</item>
|
||
/// </list>
|
||
/// 上机判据:<b>H1 若把椅子/灯渲染成两遍就是猜错了</b>,把 <c>spawnGadgetEntities</c> 改 false 即可(热重载,不用重编译)。</para>
|
||
///
|
||
/// <para><b>回滚</b>:<c>gameplay.houseFurniture.gadgets=false</c>(回到恒空映射兜底),
|
||
/// 或把两条 mid 从 <c>Enabled4229938MethodIds</c> 删掉。两者都与改动前逐字节一致。</para>
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary><c>HouseFurnitureTree4229938.PlacedIdStride</c> 的同一常量(那里是 private)。</summary>
|
||
private const ulong HousePlacedIdStride = 100000UL;
|
||
|
||
private const ulong Uint32Mask = 0xFFFFFFFFUL;
|
||
|
||
/// <summary>
|
||
/// 63581446 — <c>HouseGadgetManager.CreateGadgetsForFurnitures</c> 的请求:
|
||
/// 「这些 <c>placedId</c> 的家具,给我它们的机关实例 id」。
|
||
///
|
||
/// <para>进房子时每层各发一次(素光小筑两层会发两次,同一节点出现两遍)。
|
||
/// 客户端离开房子时 <c>DestroyGadgetsForFurnitures</c> 会发 <c>AskDestoryHouseFurnitureGadgets</c>,
|
||
/// 但<b>场景切换也会清空客户端的表</b>(<c>OnBeforeSwitchScene → ClearAll</c>),
|
||
/// 所以这里<b>每次请求都重推 <c>SyncGadgetAdd</c></b>(同一个 <c>instanceId</c>,幂等刷新),
|
||
/// 否则「出去再进来」家具就再也点不动了。</para>
|
||
/// </summary>
|
||
[Handler(MethodId.AskCreateHouseFurnitureGadgets, HandlerPacketKind.Invoke)]
|
||
private async Task OnCreateHouseFurnitureGadgets(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.HouseFurniture;
|
||
var result = new GameMethods.AskCreateHouseFurnitureGadgetsResult4229938();
|
||
|
||
if (!msg.TryGetArgs<GameMethods.AskCreateHouseFurnitureGadgetsArgs4229938>(out var args) || args is null)
|
||
{
|
||
conn.Log.Warn($"[HOUSEGADGET] AskCreateHouseFurnitureGadgets 解码失败 bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}");
|
||
await conn.ReturnAsync(msg, result);
|
||
return;
|
||
}
|
||
|
||
if (!cfg.Enabled || !cfg.Gadgets)
|
||
{
|
||
conn.Log.Info($"[HOUSEGADGET] 机关生成跳过 house={args.houseid} floor={args.floor} 件数={args.placedgadgets.Count} 原因="
|
||
+ (cfg.Enabled ? "houseFurniture.gadgets=false" : "houseFurniture.enabled=false"));
|
||
await conn.ReturnAsync(msg, result);
|
||
return;
|
||
}
|
||
|
||
var state = GetStateIfExists(conn.Session);
|
||
if (state is null)
|
||
{
|
||
conn.Log.Warn($"[HOUSEGADGET] 没有世界状态 ⇒ 只回映射不生成实体 house={args.houseid}");
|
||
await conn.ReturnAsync(msg, result);
|
||
return;
|
||
}
|
||
|
||
if (!HouseCatalog4229938.TryGet(args.houseid, out var house) || house.BuildId == 0)
|
||
{
|
||
conn.Log.Warn($"[HOUSEGADGET] 未知房产 house={args.houseid} ⇒ 空映射(件数={args.placedgadgets.Count})");
|
||
await conn.ReturnAsync(msg, result);
|
||
return;
|
||
}
|
||
|
||
var baseOffset = (args.houseid * HousePlacedIdStride) & Uint32Mask;
|
||
var spawned = 0;
|
||
var reused = 0;
|
||
var noGadget = 0;
|
||
var unknownNode = 0;
|
||
var details = new List<string>();
|
||
|
||
// 先把「要摆什么」全部解析出来,再决定走哪条通道发(见 cfg.Channel)。
|
||
// 2026-10-01 上机:add 通道服务端日志全对(44 条 [GADGET] add),但客户端
|
||
// GadgetInstanceRoot 下没有任何家具实体 ⇒ 换 addList 再试一次。
|
||
var specs = new List<GadgetSpawnSpec>();
|
||
var wanted = new List<(ulong PlacedId, uint PathId)>();
|
||
|
||
foreach (var placedId in args.placedgadgets)
|
||
{
|
||
if (placedId == 0)
|
||
continue;
|
||
|
||
var nodeId = ResolveNodeIdFromPlacedId(placedId, args.houseid, baseOffset);
|
||
if (nodeId == 0)
|
||
{
|
||
unknownNode++;
|
||
continue;
|
||
}
|
||
|
||
if (!HouseFurnitureTemplateCatalog4229938.TryGetNode(house.BuildId, nodeId, out var node))
|
||
{
|
||
unknownNode++;
|
||
continue;
|
||
}
|
||
|
||
if (!HouseFurnitureGadgetCatalog4229938.TryGetGadgetPathId(node.FurnitureId, out var pathId))
|
||
{
|
||
// 客户端理论上只为 PathId != 0 的家具发请求;真收到了也照实跳过。
|
||
noGadget++;
|
||
continue;
|
||
}
|
||
|
||
var (x, y, z) = ApplyGadgetOffset(node, node.FurnitureId);
|
||
|
||
if (!cfg.SpawnGadgetEntities)
|
||
{
|
||
reused++;
|
||
result.placedID2GadgetInstanceID[placedId] = placedId;
|
||
if (details.Count < 6)
|
||
details.Add($"{placedId}(pathId {pathId}, 不生成实体)");
|
||
continue;
|
||
}
|
||
|
||
// uniqueId = placedId ⇒ 实体 id 与返回给客户端的 gadgetInstanceId 是同一个数,
|
||
// 客户端「按 instanceId 找实体」才找得到。
|
||
specs.Add(new GadgetSpawnSpec((int)pathId, x, y, z, node.RotationY, placedId, ""));
|
||
wanted.Add((placedId, pathId));
|
||
}
|
||
|
||
var channel = (cfg.Channel ?? "add").Trim().ToLowerInvariant();
|
||
var sentAdd = false;
|
||
var sentAddList = false;
|
||
|
||
if (specs.Count > 0)
|
||
{
|
||
if (channel is "addlist" or "both")
|
||
{
|
||
var batch = await SpawnGadgetListAsync(conn.Session, state, specs, houseFurniture: true);
|
||
sentAddList = batch.Ok;
|
||
if (!batch.Ok)
|
||
conn.Log.Warn($"[HOUSEGADGET] 批量生成失败 house={args.houseid} 件数={specs.Count}: {batch.Message}");
|
||
}
|
||
|
||
if (channel is "add" or "both")
|
||
{
|
||
sentAdd = true;
|
||
foreach (var spec in specs)
|
||
{
|
||
var (ok, message, _) = await SpawnGadgetAsync(
|
||
conn.Session, state, spec.PathId, spec.X, spec.Y, spec.Z, spec.Yaw,
|
||
channel: GadgetSendChannel.Add, uniqueId: spec.UniqueId);
|
||
|
||
if (!ok)
|
||
conn.Log.Warn($"[HOUSEGADGET] 生成失败 placedId={spec.UniqueId} pathId={spec.PathId}: {message}");
|
||
}
|
||
}
|
||
|
||
foreach (var (placedId, pathId) in wanted)
|
||
{
|
||
spawned++;
|
||
result.placedID2GadgetInstanceID[placedId] = placedId;
|
||
if (details.Count < 6)
|
||
details.Add($"{placedId}→{placedId}(pathId {pathId})");
|
||
}
|
||
}
|
||
|
||
conn.Log.Info($"[HOUSEGADGET] house={args.houseid}({house.Name}) build={house.BuildId} floor={args.floor} "
|
||
+ $"请求={args.placedgadgets.Count} → 映射={result.placedID2GadgetInstanceID.Count} "
|
||
+ $"(生成实体={spawned} 仅回id={reused} 无机关={noGadget} 节点未知={unknownNode}) "
|
||
+ $"spawnEntities={cfg.SpawnGadgetEntities} channel={channel} add={sentAdd} addList={sentAddList} "
|
||
+ $"样例=[{string.Join(", ", details)}]");
|
||
|
||
await conn.ReturnAsync(msg, result);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 63759452 — <c>HouseGadgetManager.DestroyGadgetsForFurnitures</c>(官方拼写就是 <b>Destory</b>)。
|
||
/// 离开房子 / 切场景时客户端会发;应答是 <b>void</b>(客户端 reader 是空函数)。
|
||
/// </summary>
|
||
[Handler(MethodId.AskDestoryHouseFurnitureGadgets, HandlerPacketKind.Invoke)]
|
||
private async Task OnDestoryHouseFurnitureGadgets(Connection conn, UxRpcMessage msg)
|
||
{
|
||
if (!msg.TryGetArgs<GameMethods.AskDestoryHouseFurnitureGadgetsArgs4229938>(out var args) || args is null)
|
||
{
|
||
conn.Log.Warn($"[HOUSEGADGET] AskDestoryHouseFurnitureGadgets 解码失败 bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var state = GetStateIfExists(conn.Session);
|
||
if (state is null || args.placedfurnitures.Count == 0)
|
||
{
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var ids = new List<ulong>();
|
||
lock (state.SyncRoot)
|
||
{
|
||
foreach (var placedId in args.placedfurnitures)
|
||
{
|
||
if (placedId != 0 && state.SpawnedGadgets.Remove(placedId))
|
||
ids.Add(placedId);
|
||
}
|
||
}
|
||
|
||
if (ids.Count > 0)
|
||
{
|
||
await conn.NotifyAsync(
|
||
MethodId.SyncGadgetRemoveList,
|
||
new SceneMethods.SyncGadgetRemoveListArgs4229938
|
||
{
|
||
ids = ids,
|
||
reason = GadgetDisposeReasonGm,
|
||
});
|
||
}
|
||
|
||
conn.Log.Info($"[HOUSEGADGET] 销毁机关 house={args.houseid} floor={args.floor} "
|
||
+ $"请求={args.placedfurnitures.Count} 实销={ids.Count}");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★ 2026-10-01:把家具机关实体在<b>每次网格 AOI 推送之后</b>补回来。
|
||
///
|
||
/// <para><b>为什么必须补</b>:<c>SyncGadgetGridAOIDecrease</c> 的 <c>addGridInfo</c> 在客户端是
|
||
/// 「<b>当前 AOI 快照</b>」语义 —— 本仓 <c>GameRouter.GadgetGrid.cs</c> 的注释早就记过这一点
|
||
/// (「an AOIMove that only carried the newly entered cells made every previously streamed prop
|
||
/// disappear」)。世界摆件(摊位 / 派对家具 / 健身房器材)被撤掉后,客户端能从<b>自己的
|
||
/// blob</b>里再铺出来,所以它们没事;而家具机关是<b>纯服务端造</b>的、blob 里没有
|
||
/// ⇒ 一撤就永久消失。</para>
|
||
///
|
||
/// <para><b>实测铁证</b>(<c>E:\anatacbt\log\Player.log</c>,2026-10-01 10:23–10:24):
|
||
/// <code>
|
||
/// 10:23:26.554 S2C NOTIFY SyncGadgetAddList (3731b) ← 33 件建出来
|
||
/// 10:23:34.103 [DRMK-HG] entityHits=33/33 ← 全在
|
||
/// 10:23:37.496 S2C NOTIFY SyncGadgetGridAOIDecrease (1363b) ← 第一次网格快照
|
||
/// 10:23:56.345 [DRMK-HG] entityHits=0/33 ← 全没了
|
||
/// </code>
|
||
/// 该窗口内<b>唯一</b>的机关类 S2C 就是 <c>SyncGadgetGridAOIDecrease</c>(每 5 秒一次,
|
||
/// <c>cells=81</c>)。</para>
|
||
///
|
||
/// <para>补发走<b>同一条通道</b>(<c>SyncGadgetAddList</c>)且 <c>uniqueId = placedId</c> 不变 ——
|
||
/// 与 <c>OnCreateHouseFurnitureGadgets</c> 里那条「每次请求都重推」是同一个幂等刷新手法,
|
||
/// 不会多出第二份家具。只补玩家附近(<300 m)那一套房的,跑到别的区就不做无用功。</para>
|
||
///
|
||
/// <para>关掉:<c>gameplay.houseFurniture.respawnAfterGridPush=false</c>(热重载,不用重编译)。</para>
|
||
/// </summary>
|
||
internal static async Task TickHouseFurnitureGadgetsAsync(
|
||
TcpSession session, WorldEntryState state, float px, float pz)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.HouseFurniture;
|
||
if (!cfg.Enabled || !cfg.Gadgets || !cfg.SpawnGadgetEntities || !cfg.RespawnAfterGridPush)
|
||
return;
|
||
|
||
List<GadgetSpawnSpec> specs;
|
||
lock (state.SyncRoot)
|
||
{
|
||
specs = state.SpawnedGadgets.Values
|
||
.Where(r => r.IsHouseFurniture && r.PathId != 0 && IsNearPlayer(r.X, r.Z, px, pz))
|
||
.Select(r => new GadgetSpawnSpec(r.PathId, r.X, r.Y, r.Z, r.Yaw, r.InstanceId, r.Name))
|
||
.ToList();
|
||
}
|
||
|
||
if (specs.Count == 0)
|
||
return;
|
||
|
||
var batch = await SpawnGadgetListAsync(session, state, specs, houseFurniture: true);
|
||
if (!batch.Ok)
|
||
session.Log.Warn($"[HOUSEGADGET] 网格推送后补发失败 {specs.Count} 件:{batch.Message}");
|
||
}
|
||
|
||
/// <summary>只补玩家附近那一套房的机关;玩家跑到别的区就不做无用功。</summary>
|
||
private static bool IsNearPlayer(float x, float z, float px, float pz)
|
||
{
|
||
var dx = x - px;
|
||
var dz = z - pz;
|
||
return dx * dx + dz * dz <= HouseFurnitureRespawnRadiusSq;
|
||
}
|
||
|
||
private const float HouseFurnitureRespawnRadiusSq = 300f * 300f;
|
||
|
||
/// <summary>
|
||
/// 把客户端发来的 <c>placedId</c> 反解成模板节点号。
|
||
///
|
||
/// <para>⛔ <b>线上是 32 位截断形态</b>:客户端发的是
|
||
/// <c>(houseId * 100000 + nodeId) & 0xFFFFFFFF</c>,高 4 字节恒为 0
|
||
/// (实测绿屿首件 <c>93 8A 38 C5 00 00 00 00</c> = <c>0xC5388A93</c>,
|
||
/// 而 <c>0xC5388A93 - (11200003*100000 mod 2^32) = 14003</c>)。
|
||
/// 因为 <c>houseId ≥ 11200003</c> ⇒ <c>houseId*100000 ≈ 1.12e12 > 2^32</c>,截断必然发生。</para>
|
||
///
|
||
/// <para>同时兼容「万一某条链路发的是完整 64 位值」:<c>placedId > 0xFFFFFFFF</c>
|
||
/// 时直接减 <c>houseId*100000</c>。两条路都算一遍、只认落在
|
||
/// <c>1..99999</c>(模板节点号上限实测 14150)的那个。</para>
|
||
/// </summary>
|
||
private static uint ResolveNodeIdFromPlacedId(ulong placedId, uint houseId, ulong baseOffset)
|
||
{
|
||
if (placedId > Uint32Mask)
|
||
{
|
||
var full = placedId - houseId * HousePlacedIdStride;
|
||
if (full is > 0 and < HousePlacedIdStride)
|
||
return (uint)full;
|
||
}
|
||
|
||
var truncated = (placedId - baseOffset) & Uint32Mask;
|
||
return truncated < HousePlacedIdStride ? (uint)truncated : 0u;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 家具节点的机关落点。模板 <c>Position</c> 本来就是世界绝对坐标,直接搬;
|
||
/// 少数家具带 <c>GadgetOffset</c>(实测 6 个 float:pos xyz + rot xyz,全表只有 2 条有值,
|
||
/// 且都不在四套房的模板树里),有就叠加上去,避免以后换模板踩坑。
|
||
/// </summary>
|
||
private static (float X, float Y, float Z) ApplyGadgetOffset(
|
||
HouseFurnitureTemplateNode4229938 node, uint furnitureId)
|
||
{
|
||
if (!HouseFurnitureGadgetCatalog4229938.TryGet(furnitureId, out var definition)
|
||
|| definition.GadgetOffset.Count < 3)
|
||
{
|
||
return (node.X, node.Y, node.Z);
|
||
}
|
||
|
||
var offset = definition.GadgetOffset;
|
||
return (node.X + offset[0], node.Y + offset[1], node.Z + offset[2]);
|
||
}
|
||
}
|