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;
///
/// 房产自带家具的「家具机关(furniture gadget)」—— 2026-10-01。
///
/// 用户报的症状:「沙发椅子已经生成了的没法交互」——
/// 家具模型都在(AskLoadHouseData 的 FloorBuildInfoDict 已上线),
/// 但椅子坐不了、衣柜开不了、电视点不亮。
///
/// 根因(一句话):客户端把家具建出来之后会再问一次服务端要机关实例 id ——
/// HouseGadgetManager.lua:CreateGadgetsForFurnitures →
/// AskCreateHouseFurnitureGadgets(63581446),读应答里的
/// mapping.placedID2GadgetInstanceID,非 0 才写进 placedId2GadgetIdMap
/// 并 gFurnitureUtils:TrySetGadgetInstanceId(furnitureGo, id)。
/// 而这条 RPC 在服务端一直是 DefaultReturnCatalog4229938.cs:624 的
/// body=[0xFF,0x01] shape="struct(631:dict7-empty)" —— 恒回空映射
/// ⇒ placedId2GadgetIdMap 恒空 ⇒ 所有机关失效。日志铁证(console-latest.log:319-321):
///
/// -> rpc AskCreateHouseFurnitureGadgets [0x03CA2D06] 274b
/// ! [RPC4229938] unimplemented AskCreateHouseFurnitureGadgets -> typed-default struct(631:dict7-empty) 2b
/// <- ret ... e=0 2b
///
///
/// ★ 为什么说「家具树本身是对的,只缺这一步」:客户端请求的 id 个数
/// 逐套精确等于该房模板树里「HouseFurnitureConfig.PathId != 0」的家具数 ——
/// 绿屿 33/33、旧物仓 3/3、观澜 6/6、素光 1/1(unknown-methods.log 请求体逐字节解码后与模板树对齐)。
/// 而且请求里的 id 正好是 PlacedInstanceId = houseId*100000 + nodeId 的 32 位截断形态
/// (绿屿首件 0xC5388A93 - 0xC5381F20 = 14003 = 模板节点号)。
///
/// ★ 机关 prefab 从哪来:HouseFurnitureConfig.PathId
/// 就是 GadgetCatalog.json 的 pathId(逐条核对过,见
/// HouseFurnitureGadgetCatalog4229938 的注释表)。位置也不用反推 ——
/// HouseFurnitureTemplateConfig 节点的 Position 就是世界绝对坐标
/// (绿屿椅 (2290.629, 160.720, 1397.878)、素光衣柜 (1096.527, 13.040, 2375.721)),
/// Rotation.Y 就是 yaw。
///
/// 本文件做的事:
///
/// - AskCreateHouseFurnitureGadgets:对请求里每个 placedId
/// 反解 nodeId → FurnitureId → PathId,回 placedId → gadgetInstanceId 映射;
/// spawnGadgetEntities=true(默认)时同时用 SyncGadgetAdd(68843189)
/// 把机关实体摆到家具节点的世界坐标上(uniqueId = placedId,与返回值同源)。
/// - AskDestoryHouseFurnitureGadgets:摘掉记录 + 推
/// SyncGadgetRemoveList(68199571),回空 body(客户端该 RPC 的应答 reader 是空函数)。
///
///
/// ⛔ 两个开关都要留(gameplay.houseFurniture):
/// gadgets 是总开关;spawnGadgetEntities 决定要不要真的 SyncGadgetAdd。
/// 后者是实测分流用的 —— 有两种可能,客户端行为决定哪一种:
///
/// - H1(默认,spawn=true):机关实体得由服务端 SyncGadgetAdd 造出来,
/// 客户端 OnGadgetSlotCompLoaded 的 gGadgetManager:GetEntitySearchByInstanceId(gadgetId) 才找得到。
/// - H2(spawn=false):机关 GameObject 本来就在家具 prefab 里
/// (RegisterFurnitureLuaSlotReplace 用的就是 houseFurniture.gadgetGameObject),
/// 服务端只需发一个唯一的非 0 id,客户端自己 TrySetGadgetInstanceId 就能把它点亮。
/// OnGadgetSlotCompLoaded 里那个 10 帧重试计时器更像 H2(id 要等 TrySetGadgetInstanceId 之后才可寻址)。
///
/// 上机判据:H1 若把椅子/灯渲染成两遍就是猜错了,把 spawnGadgetEntities 改 false 即可(热重载,不用重编译)。
///
/// 回滚:gameplay.houseFurniture.gadgets=false(回到恒空映射兜底),
/// 或把两条 mid 从 Enabled4229938MethodIds 删掉。两者都与改动前逐字节一致。
///
internal sealed partial class GameRouter
{
/// HouseFurnitureTree4229938.PlacedIdStride 的同一常量(那里是 private)。
private const ulong HousePlacedIdStride = 100000UL;
private const ulong Uint32Mask = 0xFFFFFFFFUL;
///
/// 63581446 — HouseGadgetManager.CreateGadgetsForFurnitures 的请求:
/// 「这些 placedId 的家具,给我它们的机关实例 id」。
///
/// 进房子时每层各发一次(素光小筑两层会发两次,同一节点出现两遍)。
/// 客户端离开房子时 DestroyGadgetsForFurnitures 会发 AskDestoryHouseFurnitureGadgets,
/// 但场景切换也会清空客户端的表(OnBeforeSwitchScene → ClearAll),
/// 所以这里每次请求都重推 SyncGadgetAdd(同一个 instanceId,幂等刷新),
/// 否则「出去再进来」家具就再也点不动了。
///
[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(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();
// 先把「要摆什么」全部解析出来,再决定走哪条通道发(见 cfg.Channel)。
// 2026-10-01 上机:add 通道服务端日志全对(44 条 [GADGET] add),但客户端
// GadgetInstanceRoot 下没有任何家具实体 ⇒ 换 addList 再试一次。
var specs = new List();
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);
}
///
/// 63759452 — HouseGadgetManager.DestroyGadgetsForFurnitures(官方拼写就是 Destory)。
/// 离开房子 / 切场景时客户端会发;应答是 void(客户端 reader 是空函数)。
///
[Handler(MethodId.AskDestoryHouseFurnitureGadgets, HandlerPacketKind.Invoke)]
private async Task OnDestoryHouseFurnitureGadgets(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs(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();
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);
}
///
/// ★★ 2026-10-01:把家具机关实体在每次网格 AOI 推送之后补回来。
///
/// 为什么必须补:SyncGadgetGridAOIDecrease 的 addGridInfo 在客户端是
/// 「当前 AOI 快照」语义 —— 本仓 GameRouter.GadgetGrid.cs 的注释早就记过这一点
/// (「an AOIMove that only carried the newly entered cells made every previously streamed prop
/// disappear」)。世界摆件(摊位 / 派对家具 / 健身房器材)被撤掉后,客户端能从自己的
/// blob里再铺出来,所以它们没事;而家具机关是纯服务端造的、blob 里没有
/// ⇒ 一撤就永久消失。
///
/// 实测铁证(E:\anatacbt\log\Player.log,2026-10-01 10:23–10:24):
///
/// 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 ← 全没了
///
/// 该窗口内唯一的机关类 S2C 就是 SyncGadgetGridAOIDecrease(每 5 秒一次,
/// cells=81)。
///
/// 补发走同一条通道(SyncGadgetAddList)且 uniqueId = placedId 不变 ——
/// 与 OnCreateHouseFurnitureGadgets 里那条「每次请求都重推」是同一个幂等刷新手法,
/// 不会多出第二份家具。只补玩家附近(<300 m)那一套房的,跑到别的区就不做无用功。
///
/// 关掉:gameplay.houseFurniture.respawnAfterGridPush=false(热重载,不用重编译)。
///
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 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}");
}
/// 只补玩家附近那一套房的机关;玩家跑到别的区就不做无用功。
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;
///
/// 把客户端发来的 placedId 反解成模板节点号。
///
/// ⛔ 线上是 32 位截断形态:客户端发的是
/// (houseId * 100000 + nodeId) & 0xFFFFFFFF,高 4 字节恒为 0
/// (实测绿屿首件 93 8A 38 C5 00 00 00 00 = 0xC5388A93,
/// 而 0xC5388A93 - (11200003*100000 mod 2^32) = 14003)。
/// 因为 houseId ≥ 11200003 ⇒ houseId*100000 ≈ 1.12e12 > 2^32,截断必然发生。
///
/// 同时兼容「万一某条链路发的是完整 64 位值」:placedId > 0xFFFFFFFF
/// 时直接减 houseId*100000。两条路都算一遍、只认落在
/// 1..99999(模板节点号上限实测 14150)的那个。
///
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;
}
///
/// 家具节点的机关落点。模板 Position 本来就是世界绝对坐标,直接搬;
/// 少数家具带 GadgetOffset(实测 6 个 float:pos xyz + rot xyz,全表只有 2 条有值,
/// 且都不在四套房的模板树里),有就叠加上去,避免以后换模板踩坑。
///
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]);
}
}