Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.House.cs
T

314 lines
16 KiB
C#
Raw Normal View History

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;
/// <summary>
/// 房产(House)所有权 —— 2026-09-30。用户报的症状:
/// 「手机 app 里买房产会播动画 + 显示已购买,但实际走到那栋房子显示未购买」。
///
/// <para><b>根因(一句话)</b>:客户端判断「这套房是不是我的」只有一条路径 ——
/// <c>gBuyHouseUtils.CheckHasBuyTheHouse(houseId)</c> →
/// <c>gPlayerManager.infoMinor.bindData.housesInfo.HouseInfoList</c> →
/// <c>PlayerInfoMinorData.lua:54</c> 的 <c>t.housesInfo = info.InfoMinor.housesInfo</c>。
/// 也就是说<b>所有权完全由登录包决定</b>,而服务端从来没往里写过。
/// 手机里那套「已购买」是 <c>BuyHouseUtils.BuyTheHouse</c> 拿到 error=0 之后的
/// <b>本地乐观行为</b>(自己 <c>SendMessage(ON_BUY_HOUSE_SUCCESS)</c> + 开
/// <c>HOUSE_REWARD_PANEL</c> 播 <c>HouseConfig.GameVideo</c>),
/// 服务端当时既不记账也不推包 —— 所以面板一关 / 一重登 / 一开大地图,客户端重算一次就翻回「未购买」。</para>
///
/// <para><b>本文件负责三件事</b>:</para>
/// <list type="number">
/// <item><b>记账</b>:<c>GrantHouseOwnershipAsync</c> —— 写
/// <c>WorldEntryState.Progress.OwnedHouseIds</c>(<c>PlayerProgressStore</c> 落盘),
/// 并推一次 <c>SyncAddHouse</c>(64459483) 让<b>本次会话</b>立刻生效
/// (登录包里没有它,客户端得靠这条增量补上)。</item>
/// <item><b>推送</b>:<c>PushSyncAddHouseAsync</c> —— 线格式见
/// <see cref="HouseOwnershipWire4229938"/>。</item>
/// <item><b>读房</b>:<c>AskLoadHouseData</c>(63109556) —— 客户端 C# 侧
/// <c>AskLoadHouseData(uint houseId) : UXRPCTask&lt;HouseBuildingClientData&gt;</c>
/// 唯一的用途是拿「这栋房子的装修内容」。<b>2026-09-30 起这里还负责下发
/// 「房子自带的家具」</b>(<c>gameplay.houseFurniture</c>,见
/// <c>HouseFurnitureTemplateCatalog4229938</c>)—— 在此之前一律回 57 字节空房,
/// 那就是玩家看到的<b>毛坯房</b>。同时保证 <c>OwnerPid</c> 是<b>自己</b>而不是 0
/// (兜底表给的是 0)。</item>
/// </list>
///
/// <para><b>本轮明确不做</b>(用户拍板「只做所有权落地」+「自带家具」):
/// 车库 / 停车(<c>SyncHouseParking</c>)、家具编辑 / 购买 / 摆放
/// (<c>AskAddBuildHouseIndoor</c> / <c>AskSaveHouseConfiguration</c> / <c>AskModifyHouse</c> /
/// 墙体编辑 / livehouse)、鱼缸与收藏品的运行时数据(<c>FurnitureGadgetDataDict</c>)、
/// 访客与点赞、以及真实扣款(保持免费)。</para>
///
/// <para><b>配置依赖</b>:<c>Configs/HouseConfig.json</c>(5 条,见
/// <see cref="HouseCatalog4229938"/>)。商店侧 <c>NpcShopCatalog.json</c> 早已含 94100080
/// 「新启房产」,5 件商品齐全,所以 <c>AskNpcShopCommodityInfo</c> 不用改。</para>
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// 购买落地:记所有权 + 落盘 + 推 <c>SyncAddHouse</c>。
///
/// <para>⛔ <b>只在「第一次拿到」时推</b>。<c>BuyHouseUtils.SyncAddHouse</c> 是
/// <c>table.insert</c>(不是 set),同一套房推两次客户端列表里就有两条重复项。
/// 已拥有时静默返回 —— 登录包已经把这条给过客户端了。</para>
///
/// <returns>写进日志的结果串(<c>granted</c> / <c>already-owned</c> / <c>no-world</c>)。</returns>
/// </summary>
internal static async Task<string> 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";
}
/// <summary>
/// 推 <c>SyncAddHouse</c>(64459483):payload 是**单个** <c>HouseBaseInfo</c> 的 complex。
///
/// <para>⛔ 直接把 <c>HouseActiveInfo</c> 交给 <c>NotifyAsync&lt;T&gt;</c> ——
/// <c>UxSerializer</c> 对非 Inline 的 <c>[UxContract]</c> 类型会自己写那一个 0xFF 包装,
/// 正好对上客户端的 <c>Base.ReadComplex(reader, Auto.Reader[368])</c>。
/// **不要**再包一层 Inline 壳(多一个 0xFF ⇒ 整包错位)。</para>
/// </summary>
private static async Task PushSyncAddHouseAsync(TcpSession session, uint houseId, ServerLogger log)
{
// 连同车库车位指派一起发(与登录包同源),否则本次会话里 SyncAddHouse 之后
// 车库仍是空的。
Dictionary<int, uint>? 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)}");
}
/// <summary>
/// 63109556 — 客户端 C# 侧唯一调用点:
/// <c>UXRPCTask&lt;HouseBuildingClientData&gt; AskLoadHouseData(uint houseId)</c>。
/// 入参就是一个裸 u32(<c>ClientToGameDelegate.lua</c> 的
/// <c>AskLoadHouseData_Serializer</c> 只写一个 primitive),返回一个 complex(590)。
///
/// <para><b>2026-09-30 起这条路要下发「房子自带的家具」</b>。在此之前我们一律回
/// 权威兜底表那份 57 字节全空 <c>HouseBuildingClientData</c>
/// (<c>DefaultReturnCatalog4229938</c> 的 <c>case 63109556u</c>,逐字节由
/// <c>Auto.Reader[590]</c> 推出)—— 只把 <c>OwnerPid</c> 换成自己的 pid,
/// 因为兜底给的是 0(「无主」),跟所有权落地直接冲突。
/// 空 <c>FloorBuildInfoDict</c> 就是<b>毛坯房</b>的直接原因。</para>
///
/// <para><b>现在</b>:<c>houseId → HouseConfig.BuildId → HouseBuildConfig.IndoorId /
/// Floors / FurnitureTemplateIds → HouseFurnitureTemplateConfig</c>,
/// 把模板树翻成 <c>PlacedFurnitureInfo</c> 树塞进 <c>FloorBuildInfoDict</c>。
/// 详见 <see cref="HouseFurnitureTree4229938"/> 与
/// <see cref="HouseBuildingClientData4229938"/>(线格式逐字对照在那儿)。</para>
///
/// <para><b>兜底仍然保留</b>:关掉开关 / houseId 查不到 / 房子没有可装修体
/// (岸北悠居公寓 <c>BuildId = 0</c>)/ 模板树为空 —— 任一条命中就回 57 字节空房,
/// 和改动前逐字节一致。</para>
/// </summary>
[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})");
}
/// <summary>
/// 「自带家具」下发分支。<returns>true = 已经回过包,调用方直接 return。</returns>
///
/// <para>任何一步查不到都返回 false,让调用方走 57 字节兜底 —— 这样出问题时的表现
/// 和改动前一模一样(毛坯),不会退化成半截包 / 空包。</para>
/// </summary>
private async Task<bool> 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;
}
/// <summary>
/// <c>FloorBuildInfoDict</c> 的键。
///
/// <para><b>结论(2026-09-30 取证):键 = 楼层号,<u>1 基</u>,地面层 = 1,取值 <c>1..Floors</c>。</b>
/// 所以默认 <c>floor</c> 就是正解。</para>
///
/// <para>证据链(全部来自客户端自己的文件,不是推测):</para>
/// <list type="number">
/// <item><c>HouseBuildConfig.FloorsName</c> = <c>[{floor:1,__RAW__name:"1F"}]</c>;
/// 素光小筑 <c>[{1,"1F"},{2,"2F"}]</c>,且长度恰好等于 <c>Floors</c>。</item>
/// <item><c>HouseBuildConfig.BuildBound</c> / <c>CameraBound</c> / <c>CameraTopViewPoint</c>
/// 一律用 <c>floor: 1,2</c>(同时带 <c>IndoorBoundId: 1,2</c>,数值与 floor 相同)。</item>
/// <item><c>HomeBuildPanelStore_Floor.lua</c>:<c>SelectOption(initFloor - 1)</c>、
/// <c>houseCamera.floor = index + 1</c> ⇒ 游戏侧 floor 是 1 基、只有 UI 选项是 0 基;
/// <c>GetFloorByPlayerPosition</c> 两处兜底都 <c>return 1</c>。</item>
/// <item><c>LuaGmUtils.lua:166</c> 的
/// <c>for floorKey, indoorBuildInfo in pairs(buildData.FloorBuildInfoDict)</c> —— 变量名就叫
/// <c>floorKey</c>;而且它<b>对每个键都加载一遍家具</b>(去重表 <c>loadedFurnitureIds</c> 建在
/// 键循环体内部)⇒ <b>多写键 = 家具被创建多遍</b>。所以默认绝不能「把所有候选键都写上碰运气」。</item>
/// <item><c>HouseUtils.lua</c> 三个函数(Add/Remove/ModifyOneFurnitureInfo)里的
/// <c>floor = floor or 0</c> 只是「参数缺失」的兜底;0 本身是非法值(会走
/// <c>"无法找到楼层[0]的家具信息"</c> 的 warn 分支)。</item>
/// </list>
///
/// <para><c>indoor</c> / <c>all</c> 保留为<b>诊断开关</b>:<c>GmSyncHouseIndoorBuildInfo(houseId,
/// indoorId, …)</c> 那条 GM 链路确实按 indoorId 组织,万一登录链路将来被证伪可立刻切过去验证。
/// <c>all</c> 会同时写 <c>1..Floors</c> 与 <c>IndoorId</c>,<b>一定产生重复家具</b>,只用于确认
/// 「客户端到底取哪个键」。</para>
/// </summary>
private static List<uint> ResolveFurnitureFloorKeys(string? mode, HouseBuildDefinition4229938 build)
{
var keys = new List<uint>();
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<HouseFurnitureTemplateNode4229938> 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);
}
}
}