using Ananta.SDK.Network; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// General restock of client-blob props: whenever the player walks into a world cell whose props /// were captured out of LX6.Item.GadgetBlob, the server places them itself. /// /// This is the same trick as widened to every prop family — /// vending machines, indoor chairs and sofas, claw machines, mini-game cabinets, basketball /// courts, bouncy beds, market stalls. None of them are in SceneitemConfig.json by pathId, /// and the grid-AOI push (68956797) alone does not make the client instantiate them, so the server /// has to place them with SyncGadgetAdd using the blob's own uid and placement. /// /// ⛔ PackedGadgetInfo.iScale must be 0 — see PropRestockCatalog4229938 remarks. /// /// Rollback: world.content.propRestock.enabled = false. /// internal sealed partial class GameRouter { private const string PropRestockKey = "client4229938-prop-restock"; internal sealed class PropRestockSessionState { internal object SyncRoot { get; } = new(); /// "raid:cx,cz" cells already stocked this session. internal HashSet Placed { get; } = new(StringComparer.Ordinal); internal int PlacedCount; internal bool WarnedEmpty; /// 已经提示过「本张图没有点位数据」—— 每个会话只说一次。 internal bool WarnedNoRaid; /// Catalog 加载期告警已经打印过(每会话一次)。★ R143 补:见下文。 internal bool WarnedCatalog; } private static PropRestockSessionState PropRestockStateOf(TcpSession session) { if (session.Items.TryGetValue(PropRestockKey, out var existing) && existing is PropRestockSessionState typed) return typed; var created = new PropRestockSessionState(); session.Items[PropRestockKey] = created; return created; } /// Reconnect / scene switch: forget what was stocked so the next visit re-places it. internal static void ResetPropRestock(TcpSession session) { var owner = PropRestockStateOf(session); lock (owner.SyncRoot) { owner.Placed.Clear(); owner.PlacedCount = 0; owner.WarnedEmpty = false; owner.WarnedNoRaid = false; owner.WarnedCatalog = false; } } /// /// Called from the grid-AOI tick. Stocks every captured cell within /// PropRestockSettings.RadiusCells of the player's current cell, once per cell. /// internal static async Task TickPropRestockAsync( TcpSession session, WorldEntryState state, float px, float pz) { var cfg = PrivateServerConfigStore.Current.World.Content.PropRestock; if (!cfg.Enabled) return; var owner = PropRestockStateOf(session); if (PropRestockCatalog4229938.Count == 0) { lock (owner.SyncRoot) { if (owner.WarnedEmpty) return; owner.WarnedEmpty = true; } session.Log.Info($"[RESTOCK] 没有可用点位:{PropRestockCatalog4229938.LoadError}"); return; } // ★★★★★ R143 —— 加载期告警必须露一次脸(v1.9 R95 教训,铁律 102)。 // 典型:某条 blob uid 超出 ulong ⇒ 加载器按 mod 2^64 回绕。若这条不打印, // 「uid 解析失败 ⇒ 每次补货 mint 新实例 ⇒ 同坐标多出一件」可以潜伏几个版本, // 而表现只是用户看到「两个摊叠在一起」(R95 就漏看过一次)。 // ⚠️ 本文件的 raid 维度逻辑(下方)是 v2.0 上游新增的,比 v1.9 那份更完整 —— 两者都要。 lock (owner.SyncRoot) { if (!owner.WarnedCatalog) { owner.WarnedCatalog = true; foreach (var warning in PropRestockCatalog4229938.LoadWarnings) session.Log.Warn($"[RESTOCK] 配置告警:{warning}"); } } // ⛔ (cx,cz) 只在单张地图内唯一:新启 23300888 和 重霄·凌云 23300999 的格子编号会撞车。 // 少这一步,第二张图采集来的物件会刷在第一张图上。 var raid = state.ActiveRaidId != 0 ? state.ActiveRaidId : PrivateServerConfigStore.Current.World.RaidId; if (PropRestockCatalog4229938.CountForRaid(raid) == 0) { lock (owner.SyncRoot) { if (owner.WarnedNoRaid) return; owner.WarnedNoRaid = true; } // 常见情况:整表数据都来自另一张地图。别静默什么都不刷,说清楚。 session.Log.Info($"[RESTOCK] 点位表共 {PropRestockCatalog4229938.Count} 格," + $"但没有 raid={raid} 的数据(已有 raid:" + $"{string.Join(",", PropRestockCatalog4229938.Raids)})"); return; } var cellSize = PropRestockCatalog4229938.CellSize > 0 ? PropRestockCatalog4229938.CellSize : 40; var cx = (int)Math.Floor(px / cellSize); var cz = (int)Math.Floor(pz / cellSize); var channel = cfg.Channel switch { 1 => GadgetSendChannel.Aoi, 3 => GadgetSendChannel.AddList, _ => GadgetSendChannel.Add, }; foreach (var cell in PropRestockCatalog4229938.NearCell(raid, cx, cz, cfg.RadiusCells)) { var key = $"{raid}:{cell.Cx},{cell.Cz}"; lock (owner.SyncRoot) { if (!owner.Placed.Add(key)) continue; } session.Log.Info($"[RESTOCK] raid={raid} 进入格子 ({cell.Cx},{cell.Cz}),补货 {cell.Items.Count} 件 " + $"channel={channel} logOnly={cfg.LogOnly}"); if (cfg.LogOnly) continue; var placed = 0; if (channel == GadgetSendChannel.AddList) { var specs = cell.Items .Select(i => new GadgetSpawnSpec(i.PathId, i.X, i.Y, i.Z, i.Yaw, i.UniqueId, i.Name)) .ToList(); var batch = await SpawnGadgetListAsync(session, state, specs, cfg.IScale); placed = batch.Ok ? batch.Count : 0; if (!batch.Ok) session.Log.Warn($"[RESTOCK] 格子 ({cell.Cx},{cell.Cz}) 批量放置失败:{batch.Message}"); } else { foreach (var item in cell.Items) { var result = await SpawnGadgetAsync( session, state, item.PathId, item.X, item.Y, item.Z, item.Yaw, channel, item.UniqueId, cfg.IScale); if (result.Ok) placed++; else session.Log.Warn($"[RESTOCK] {item.Name}({item.PathId}) 放置失败:{result.Message}"); } } lock (owner.SyncRoot) owner.PlacedCount += placed; session.Log.Info($"[RESTOCK] 格子 ({cell.Cx},{cell.Cz}) 放置完成 {placed}/{cell.Items.Count}"); } } }