201 lines
7.9 KiB
C#
201 lines
7.9 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 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 <c>LX6.Item.GadgetBlob</c>, 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
|
||
/// (<c>uniqueIdToSceneGridSeekId</c> → <c>GetGadgetBlobData</c> → PackedGadgetInfo) and finishes
|
||
/// with <c>SyncGadgetAddList(List<GadgetEntityInfo>)</c>. 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: <c>world.content.gymEquipment.enabled = false</c>.
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
private const string GymEquipmentKey = "client4229938-gym-equipment";
|
||
|
||
internal sealed class GymEquipmentSessionState
|
||
{
|
||
internal object SyncRoot { get; } = new();
|
||
|
||
/// <summary>Venues already stocked this session. One shot per venue, like the loader.</summary>
|
||
internal HashSet<string> Placed { get; } = new(StringComparer.Ordinal);
|
||
|
||
internal int PlacedCount;
|
||
|
||
/// <summary>「切室内 / 传送中,本次跳过」只打一次日志,避免每 tick 刷屏。</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Reconnect / scene switch: forget what was stocked so the next visit re-places it.</summary>
|
||
internal static void ResetGymEquipment(TcpSession session)
|
||
{
|
||
var owner = GymStateOf(session);
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.Placed.Clear();
|
||
owner.PlacedCount = 0;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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}");
|
||
}
|
||
}
|
||
}
|