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}");
}
}
}