using Ananta.SDK.Network; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// Gym equipment the server places itself — the server-side half of what AnantaGymLoader.exe does /// by frida injection. /// /// Why it is needed: the wodegym_* prefabs (treadmill 1371296286, bench press 1443522735, squat /// rack 434521152, sit-up board 344334257, bench 684457731 — verified 2026-09-23 by uid-pairing /// spawned instance names against blob records; the loader's own hard-coded 79302023/22/21 and /// 79000586 are WRONG for this build) are absent from GadgetCatalog.json and from /// SceneitemConfig.json. Their only home is the client's own LX6.Item.GadgetBlob, which is /// populated by a pipeline our grid-AOI push (68956797) never drives: /// GadgetBlob.OnEnterScene("WorldMap_Release") → ReadHeadInfo → InitSimpleGrid → /// OnAoiAddBlobCfgByGridIndex(x, z) per cell. /// The loader injects those calls, then reads the blob dictionary /// (uniqueIdToSceneGridSeekId → GetGadgetBlobData → PackedGadgetInfo) and finishes /// with SyncGadgetAddList(List<GadgetEntityInfo>). The minigames themselves are client /// lua, so the server only owes the placement — which is exactly what this file does with the /// coordinates captured into Configs/GymEquipment.json. /// /// Rollback: world.content.gymEquipment.enabled = false. /// internal sealed partial class GameRouter { private const string GymEquipmentKey = "client4229938-gym-equipment"; internal sealed class GymEquipmentSessionState { internal object SyncRoot { get; } = new(); /// Venues already stocked this session. One shot per venue, like the loader. internal HashSet Placed { get; } = new(StringComparer.Ordinal); internal int PlacedCount; /// 「切室内 / 传送中,本次跳过」只打一次日志,避免每 tick 刷屏。 internal bool WarnedBusy; } private static GymEquipmentSessionState GymStateOf(TcpSession session) { if (session.Items.TryGetValue(GymEquipmentKey, out var existing) && existing is GymEquipmentSessionState typed) return typed; var created = new GymEquipmentSessionState(); session.Items[GymEquipmentKey] = created; return created; } /// Reconnect / scene switch: forget what was stocked so the next visit re-places it. internal static void ResetGymEquipment(TcpSession session) { var owner = GymStateOf(session); lock (owner.SyncRoot) { owner.Placed.Clear(); owner.PlacedCount = 0; } } /// /// Called from the grid-AOI tick (which already knows the player position). Places the /// equipment of every venue the player has walked into, once per venue per session. /// internal static async Task TickGymEquipmentAsync( TcpSession session, WorldEntryState state, float px, float pz) { var cfg = PrivateServerConfigStore.Current.World.Content.GymEquipment; if (!cfg.Enabled) return; var owner = GymStateOf(session); if (owner.PlacedCount == 0 && GymEquipmentCatalog4229938.Count == 0) { // One line per session instead of per tick — a missing capture file is a setup step, // not a runtime error. lock (owner.SyncRoot) owner.PlacedCount = -1; session.Log.Info($"[GYM] 没有可用点位:{GymEquipmentCatalog4229938.LoadError}"); return; } // ★ 2026-10-01 千泊实测守卫:客户端正在切室内 / 正在走传送流程时,SyncGadgetAdd 会被整批吞掉, // 而场馆每会话只铺一次 ⇒ 一次丢 = 整场空。这里**不下发也不记 Placed**,等下一个 tick 再试。 var now = Environment.TickCount64; bool teleporting; long indoorReportMs; lock (state.SyncRoot) { teleporting = state.PendingTeleportId != 0; indoorReportMs = state.ClientIndoorReportMs; } if (cfg.SkipDuringTeleport && teleporting) { if (!owner.WarnedBusy) { owner.WarnedBusy = true; session.Log.Info("[GYM] 传送流程未结束,本次不摆放(等下一个 tick 重试)"); } return; } if (cfg.SettleAfterIndoorChangeMs > 0 && indoorReportMs != 0 && now - indoorReportMs < cfg.SettleAfterIndoorChangeMs) { if (!owner.WarnedBusy) { owner.WarnedBusy = true; session.Log.Info($"[GYM] 客户端刚切完室内({now - indoorReportMs}ms 前)," + $"等 {cfg.SettleAfterIndoorChangeMs}ms 稳定后再摆放"); } return; } if (owner.WarnedBusy) { owner.WarnedBusy = false; session.Log.Info("[GYM] 客户端已稳定,开始摆放"); } var channel = cfg.Channel switch { 1 => GadgetSendChannel.Aoi, 2 => GadgetSendChannel.Both, 3 => GadgetSendChannel.AddList, _ => GadgetSendChannel.Add, }; foreach (var spot in GymEquipmentCatalog4229938.WithinRadius(px, pz, 0)) { var radius = spot.Radius + cfg.RadiusBonus; if (radius > 0f) { var dx = spot.X - px; var dz = spot.Z - pz; if (dx * dx + dz * dz > radius * radius) continue; } lock (owner.SyncRoot) { if (!owner.Placed.Add(spot.Name)) continue; } if (spot.Items.Count == 0) { session.Log.Info($"[GYM] 场馆 {spot.Name} 还没有器材坐标(先跑 _gym_capture.py)"); continue; } session.Log.Info($"[GYM] 进入 {spot.Name}({spot.X:F1},{spot.Z:F1})半径 {radius:F0}m," + $"放置 {spot.Items.Count} 件器材 channel={channel} logOnly={cfg.LogOnly}"); if (cfg.LogOnly) continue; var placed = 0; if (channel == GadgetSendChannel.AddList) { // Batch: one push, and the captured blob uids go straight through. var specs = spot.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($"[GYM] {spot.Name} 批量放置失败:{batch.Message}"); } else { foreach (var item in spot.Items) { // Pass the captured blob uid: it is the instance id the client itself would have // used, so prefab resolution and any blob-side bookkeeping line up. 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($"[GYM] {spot.Name} {item.Name}({item.PathId}) 放置失败:{result.Message}"); } } lock (owner.SyncRoot) owner.PlacedCount += placed; session.Log.Info($"[GYM] {spot.Name} 放置完成 {placed}/{spot.Items.Count}"); } } }