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

315 lines
16 KiB
C#
Raw 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 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);
}
}
}