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);
}
}
}