feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio
This commit is contained in:
1 parent
74e2963a12
commit
3e5adf1dc5
1571 files changed
+336468
-6348
No files matched your search
@@ -0,0 +1,200 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user