using System.Buffers.Binary; 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 Auto = Ananta.Server.RpcTypes.Client4229938.Auto; namespace Ananta.Server.Handlers.Game; /// /// 房产(House)所有权 —— 2026-09-30。用户报的症状: /// 「手机 app 里买房产会播动画 + 显示已购买,但实际走到那栋房子显示未购买」。 /// /// 根因(一句话):客户端判断「这套房是不是我的」只有一条路径 —— /// gBuyHouseUtils.CheckHasBuyTheHouse(houseId) → /// gPlayerManager.infoMinor.bindData.housesInfo.HouseInfoList → /// PlayerInfoMinorData.lua:54 的 t.housesInfo = info.InfoMinor.housesInfo。 /// 也就是说所有权完全由登录包决定,而服务端从来没往里写过。 /// 手机里那套「已购买」是 BuyHouseUtils.BuyTheHouse 拿到 error=0 之后的 /// 本地乐观行为(自己 SendMessage(ON_BUY_HOUSE_SUCCESS) + 开 /// HOUSE_REWARD_PANEL 播 HouseConfig.GameVideo), /// 服务端当时既不记账也不推包 —— 所以面板一关 / 一重登 / 一开大地图,客户端重算一次就翻回「未购买」。 /// /// 本文件负责三件事: /// /// 记账:GrantHouseOwnershipAsync —— 写 /// WorldEntryState.Progress.OwnedHouseIds(PlayerProgressStore 落盘), /// 并推一次 SyncAddHouse(64459483) 让本次会话立刻生效 /// (登录包里没有它,客户端得靠这条增量补上)。 /// 推送:PushSyncAddHouseAsync —— 线格式见 /// 。 /// 读房:AskLoadHouseData(63109556) —— 客户端 C# 侧 /// AskLoadHouseData(uint houseId) : UXRPCTask<HouseBuildingClientData> /// 唯一的用途是拿「这栋房子的装修内容」。2026-09-30 起这里还负责下发 /// 「房子自带的家具」(gameplay.houseFurniture,见 /// HouseFurnitureTemplateCatalog4229938)—— 在此之前一律回 57 字节空房, /// 那就是玩家看到的毛坯房。同时保证 OwnerPid 是自己而不是 0 /// (兜底表给的是 0)。 /// /// /// 本轮明确不做(用户拍板「只做所有权落地」+「自带家具」): /// 车库 / 停车(SyncHouseParking)、家具编辑 / 购买 / 摆放 /// (AskAddBuildHouseIndoor / AskSaveHouseConfiguration / AskModifyHouse / /// 墙体编辑 / livehouse)、鱼缸与收藏品的运行时数据(FurnitureGadgetDataDict)、 /// 访客与点赞、以及真实扣款(保持免费)。 /// /// 配置依赖:Configs/HouseConfig.json(5 条,见 /// )。商店侧 NpcShopCatalog.json 早已含 94100080 /// 「新启房产」,5 件商品齐全,所以 AskNpcShopCommodityInfo 不用改。 /// internal sealed partial class GameRouter { /// /// 购买落地:记所有权 + 落盘 + 推 SyncAddHouse。 /// /// ⛔ 只在「第一次拿到」时推。BuyHouseUtils.SyncAddHouse 是 /// table.insert(不是 set),同一套房推两次客户端列表里就有两条重复项。 /// 已拥有时静默返回 —— 登录包已经把这条给过客户端了。 /// /// 写进日志的结果串(granted / already-owned / no-world)。 /// internal static async Task GrantHouseOwnershipAsync(TcpSession session, uint houseId, ServerLogger log) { if (houseId == 0) return "bad-house-id"; var world = GetStateIfExists(session); if (world is null) { // 理论上到不了:房产只能在世界里(手机 app)买。真到了也不该让玩家白买, // 所以照推 —— 只是这一局没得落盘,重登就没了。 log.Warn($"[HOUSE] grant house={houseId} 没有世界状态 ⇒ 无法落盘,仅本次会话生效"); await PushSyncAddHouseAsync(session, houseId, log); return "no-world"; } bool added; lock (world.SyncRoot) added = world.Progress.OwnedHouseIds.Add(houseId); if (!added) return "already-owned"; PlayerProgressStore.Save(world, session.Log); await PushSyncAddHouseAsync(session, houseId, log); return "granted"; } /// /// 推 SyncAddHouse(64459483):payload 是**单个** HouseBaseInfo 的 complex。 /// /// ⛔ 直接把 HouseActiveInfo 交给 NotifyAsync<T> —— /// UxSerializer 对非 Inline 的 [UxContract] 类型会自己写那一个 0xFF 包装, /// 正好对上客户端的 Base.ReadComplex(reader, Auto.Reader[368])。 /// **不要**再包一层 Inline 壳(多一个 0xFF ⇒ 整包错位)。 /// private static async Task PushSyncAddHouseAsync(TcpSession session, uint houseId, ServerLogger log) { // 连同车库车位指派一起发(与登录包同源),否则本次会话里 SyncAddHouse 之后 // 车库仍是空的。 Dictionary? parking = null; var world = GetStateIfExists(session); if (world is not null) lock (world.SyncRoot) world.Progress.GarageParking.TryGetValue(houseId, out parking); var body = UxSerializer.Serialize(HouseOwnershipWire4229938.ToWire(houseId, parking)); await session.NotifyAsync(MethodId.SyncAddHouse, body, CancellationToken.None); log.Info($"[HOUSE] -> SyncAddHouse(64459483) house={houseId} body={body.Length}B hex={Convert.ToHexString(body)}"); } /// /// 63109556 — 客户端 C# 侧唯一调用点: /// UXRPCTask<HouseBuildingClientData> AskLoadHouseData(uint houseId)。 /// 入参就是一个裸 u32(ClientToGameDelegate.lua 的 /// AskLoadHouseData_Serializer 只写一个 primitive),返回一个 complex(590)。 /// /// 2026-09-30 起这条路要下发「房子自带的家具」。在此之前我们一律回 /// 权威兜底表那份 57 字节全空 HouseBuildingClientData /// (DefaultReturnCatalog4229938 的 case 63109556u,逐字节由 /// Auto.Reader[590] 推出)—— 只把 OwnerPid 换成自己的 pid, /// 因为兜底给的是 0(「无主」),跟所有权落地直接冲突。 /// 空 FloorBuildInfoDict 就是毛坯房的直接原因。 /// /// 现在:houseId → HouseConfig.BuildId → HouseBuildConfig.IndoorId / /// Floors / FurnitureTemplateIds → HouseFurnitureTemplateConfig, /// 把模板树翻成 PlacedFurnitureInfo 树塞进 FloorBuildInfoDict。 /// 详见 与 /// (线格式逐字对照在那儿)。 /// /// 兜底仍然保留:关掉开关 / houseId 查不到 / 房子没有可装修体 /// (岸北悠居公寓 BuildId = 0)/ 模板树为空 —— 任一条命中就回 57 字节空房, /// 和改动前逐字节一致。 /// [Handler(MethodId.AskLoadHouseData, HandlerPacketKind.Invoke)] private async Task AskLoadHouseData(Connection conn, UxRpcMessage msg) { var houseId = msg.Body.Length >= 4 ? BitConverter.ToUInt32(msg.Body, 0) : 0u; var owned = false; var world = GetStateIfExists(msg.Context.Session); if (world is not null && houseId != 0) lock (world.SyncRoot) owned = world.Progress.OwnedHouseIds.Contains(houseId); if (await TryReturnHouseFurnitureAsync(conn, msg, houseId, owned)) return; if (!DefaultReturnCatalog4229938.TryGet(MethodId.AskLoadHouseData, out var template, out var shape) || template.Length < 9) { // 兜底表里没有这条 / 形状意外变短 —— 宁可回空包也不回半截包(半截包会让客户端 await 超时)。 conn.Log.Warn($"[HOUSE] load-house-data house={houseId} 拿不到 {template.Length}B 模板 (shape={shape}) ⇒ 回空"); await conn.ReturnEmptyOkAsync(msg); return; } var body = (byte[])template.Clone(); BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(1, 8), Profile.PlayerPid); await conn.ReturnRawAsync(msg, body); conn.Log.Info($"[HOUSE] load-house-data house={houseId} owned={owned} ownerPid={Profile.PlayerPid} " + $"-> {body.Length}B (shape={shape})"); } /// /// 「自带家具」下发分支。true = 已经回过包,调用方直接 return。 /// /// 任何一步查不到都返回 false,让调用方走 57 字节兜底 —— 这样出问题时的表现 /// 和改动前一模一样(毛坯),不会退化成半截包 / 空包。 /// private async Task TryReturnHouseFurnitureAsync( Connection conn, UxRpcMessage msg, uint houseId, bool owned) { var cfg = PrivateServerConfigStore.Current.Gameplay.HouseFurniture; if (!cfg.Enabled || houseId == 0) return false; if (!HouseCatalog4229938.TryGet(houseId, out var house)) { conn.Log.Warn($"[HOUSE] 自带家具:house={houseId} 不在 HouseConfig 里 ⇒ 回空房"); return false; } if (house.BuildId == 0) { // 11200000「岸北悠居公寓」:IsBuild = false,本来就没有可装修体。 conn.Log.Info($"[HOUSE] 自带家具:house={houseId}({house.Name}) BuildId=0(不可自建)⇒ 回空房"); return false; } if (!HouseFurnitureTemplateCatalog4229938.TryGetBuild(house.BuildId, out var build)) { conn.Log.Warn($"[HOUSE] 自带家具:build={house.BuildId} 不在 HouseBuildConfig 里 " + $"(loadError={HouseFurnitureTemplateCatalog4229938.LoadError ?? "无"}) ⇒ 回空房"); return false; } var topLevel = HouseFurnitureTemplateCatalog4229938.TopLevelNodes(house.BuildId); if (topLevel.Count == 0) { conn.Log.Warn($"[HOUSE] 自带家具:house={houseId} build={house.BuildId} " + $"模板={string.Join(",", build.FurnitureTemplateIds)} 展开后为空 ⇒ 回空房"); return false; } var root = HouseFurnitureTree4229938.BuildRoot(houseId, topLevel, out var furnitureCount); var keys = ResolveFurnitureFloorKeys(cfg.KeyMode, build); var payload = new HouseBuildingClientData4229938 { OwnerPid = Profile.PlayerPid }; foreach (var key in keys) { // 同一个 root 对象挂到多个键下 —— UxSerializer 是纯写,不会去重也不会改对象, // 所以几个键写出来的字节除了键本身完全一样(placedId 也相同)。 payload.FloorBuildInfoDict[key] = new Auto.IndoorBuildInfo { Root = root }; } var body = UxSerializer.Serialize(payload); await conn.ReturnRawAsync(msg, body); conn.Log.Info($"[HOUSE] load-house-data house={houseId}({house.Name}) owned={owned} " + $"build={house.BuildId} indoor={build.IndoorId} floors={build.Floors} " + $"模板=[{string.Join(",", build.FurnitureTemplateIds)}] 顶层={topLevel.Count} 家具={furnitureCount} " + $"键=[{string.Join(",", keys)}](mode={cfg.KeyMode}) ownerPid={Profile.PlayerPid} -> {body.Length}B"); if (cfg.VerboseLog) LogFurnitureTree(conn.Log, houseId, topLevel, 0); return true; } /// /// FloorBuildInfoDict 的键。 /// /// 结论(2026-09-30 取证):键 = 楼层号,1 基,地面层 = 1,取值 1..Floors。 /// 所以默认 floor 就是正解。 /// /// 证据链(全部来自客户端自己的文件,不是推测): /// /// HouseBuildConfig.FloorsName = [{floor:1,__RAW__name:"1F"}]; /// 素光小筑 [{1,"1F"},{2,"2F"}],且长度恰好等于 Floors。 /// HouseBuildConfig.BuildBound / CameraBound / CameraTopViewPoint /// 一律用 floor: 1,2(同时带 IndoorBoundId: 1,2,数值与 floor 相同)。 /// HomeBuildPanelStore_Floor.lua:SelectOption(initFloor - 1)、 /// houseCamera.floor = index + 1 ⇒ 游戏侧 floor 是 1 基、只有 UI 选项是 0 基; /// GetFloorByPlayerPosition 两处兜底都 return 1。 /// LuaGmUtils.lua:166 的 /// for floorKey, indoorBuildInfo in pairs(buildData.FloorBuildInfoDict) —— 变量名就叫 /// floorKey;而且它对每个键都加载一遍家具(去重表 loadedFurnitureIds 建在 /// 键循环体内部)⇒ 多写键 = 家具被创建多遍。所以默认绝不能「把所有候选键都写上碰运气」。 /// HouseUtils.lua 三个函数(Add/Remove/ModifyOneFurnitureInfo)里的 /// floor = floor or 0 只是「参数缺失」的兜底;0 本身是非法值(会走 /// "无法找到楼层[0]的家具信息" 的 warn 分支)。 /// /// /// indoor / all 保留为诊断开关:GmSyncHouseIndoorBuildInfo(houseId, /// indoorId, …) 那条 GM 链路确实按 indoorId 组织,万一登录链路将来被证伪可立刻切过去验证。 /// all 会同时写 1..Floors 与 IndoorId,一定产生重复家具,只用于确认 /// 「客户端到底取哪个键」。 /// private static List ResolveFurnitureFloorKeys(string? mode, HouseBuildDefinition4229938 build) { var keys = new List(); void Add(uint key) { if (key != 0 && !keys.Contains(key)) keys.Add(key); } var floors = build.Floors == 0 ? 1u : build.Floors; switch ((mode ?? "").Trim().ToLowerInvariant()) { case "indoor": // 诊断:GM 链路用 indoorId,登录链路不是。 Add(build.IndoorId); break; case "all": // 诊断:一定会重复创建家具,别长期开。 for (var i = 1u; i <= floors; i++) Add(i); Add(build.IndoorId); break; default: // "floor" / 空 / 写错 —— 正解 for (var i = 1u; i <= floors; i++) Add(i); break; } if (keys.Count == 0) keys.Add(1u); // 极端情况(Floors=0):至少给一个键,别写空表 return keys; } private static void LogFurnitureTree( ServerLogger log, uint houseId, IReadOnlyList nodes, int depth) { foreach (var node in nodes) { log.Info($"[HOUSE] {new string(' ', depth * 2)}node={node.NodeId} furniture={node.FurnitureId} " + $"pos=({node.X:F2},{node.Y:F2},{node.Z:F2}) rot=({node.RotationX:F1},{node.RotationY:F1},{node.RotationZ:F1}) " + $"placedId={(ulong)houseId * 100000UL + node.NodeId}"); LogFurnitureTree(log, houseId, node.Children, depth + 1); } } }