This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

377 lines
19 KiB
C#
Raw Permalink Normal View History

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
/// &lt;- 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> 里那条「每次请求都重推」是同一个幂等刷新手法,
/// 不会多出第二份家具。只补玩家附近(&lt;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) &amp; 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 &gt; 2^32</c>,截断必然发生。</para>
///
/// <para>同时兼容「万一某条链路发的是完整 64 位值」:<c>placedId &gt; 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]);
}
}