Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.HouseFurnitureGadget.cs

378 lines
19 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 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]);
}
}