314 lines
16 KiB
C#
314 lines
16 KiB
C#
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<HouseBuildingClientData></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<T></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<HouseBuildingClientData> 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);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|