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,333 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Configuration;
|
||||
using Ananta.Server.Handlers;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Interactive props: Gadget placement and DynamicGo activation.
|
||||
///
|
||||
/// Two different systems, both previously untouched by this server:
|
||||
///
|
||||
/// <b>Gadget</b> — the game's placed interactive prop (chairs, benches, arcade cabinets,
|
||||
/// computers, drill shelves). The client owns the prefabs; the server only says *which prefab and
|
||||
/// where* via SyncGadgetAdd(68843189) and the client loads and spawns it. We have the full prefab
|
||||
/// list (2935 pathIds) from the world dump, so this is the one channel where the server can put a
|
||||
/// brand-new interactive object into the world without any scene data of its own.
|
||||
///
|
||||
/// <b>DynamicGo</b> — scene-authored props that the client already has (doors, shutters, kiosks).
|
||||
/// Their ids live inside the client's scene resources, which is why the server has never been able
|
||||
/// to enumerate them. The client tells us what it activated through AskActiveDynamicGo(67339530),
|
||||
/// so the reachable half is: accept the report, remember it, and echo it back as
|
||||
/// SyncDynamicGoChange(68106491) so the state survives a relog instead of resetting.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
/// <summary>GadgetDisposeReason.Gm — "the server took it away", retail's own value for admin removals.</summary>
|
||||
private const int GadgetDisposeReasonGm = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Which S2C channel places the prop. SyncGadgetAdd(68843189) is the direct create call and was
|
||||
/// proven to reach the client; SyncGadgetAOIAddAndRemove(68169646) is the world-streaming call
|
||||
/// the client uses for props that come and go with the AOI. Both are offered because which one
|
||||
/// the client honours for a prop that no grid ever streamed is still unknown.
|
||||
/// </summary>
|
||||
internal enum GadgetSendChannel
|
||||
{
|
||||
Add = 0,
|
||||
Aoi = 1,
|
||||
Both = 2,
|
||||
/// <summary>
|
||||
/// SyncGadgetAddList(68925813) — the channel AnantaGymLoader.exe calls. Same payload as
|
||||
/// Add but batched in one push. Worth trying when Add builds the entity (name, position,
|
||||
/// interact point all correct) yet the prefab never appears: the two client handlers are
|
||||
/// different methods (0x12787ED0 vs 0x1276CF20) and may not share the resource path.
|
||||
/// </summary>
|
||||
AddList = 3,
|
||||
}
|
||||
|
||||
/// <summary>AoiAddAndRemoveReason.AOIMove — "these props entered your streaming range".</summary>
|
||||
private const int AoiAddAndRemoveReasonAoiMove = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Client invoke: AskActiveDynamicGo(67339530) — "I activated/deactivated prop id N".
|
||||
///
|
||||
/// The wire payload is 6 bytes and reads as int32 + bool + byte, which pins
|
||||
/// DynamicGoChangeReason to a single byte even though the dump types it as an int enum. The
|
||||
/// two sightings in unknown-methods.log are both reason 5 (Shop) on the same id with
|
||||
/// active 0 then 1 — a shop purchase toggling a prop, not a player pressing a switch.
|
||||
/// </summary>
|
||||
[Handler(MethodId.AskActiveDynamicGo, HandlerPacketKind.Invoke)]
|
||||
private async Task OnActiveDynamicGo(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var state = GetWorldState(msg.Context);
|
||||
if (!msg.TryGetArgs<SceneMethods.AskActiveDynamicGoArgs4229938>(out var args) || args is null)
|
||||
{
|
||||
conn.Log.Warn($"[DYNAMICGO] AskActiveDynamicGo undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
state.DynamicGoActive[args.dyId] = args.active;
|
||||
}
|
||||
|
||||
conn.Log.Info($"[DYNAMICGO] id={args.dyId} active={args.active} reason={args.reason}");
|
||||
|
||||
// The client is the one that flipped the prop locally; it only needs the echo so the
|
||||
// change is authoritative (and survives a relog) rather than a local guess.
|
||||
await conn.NotifyAsync(
|
||||
MethodId.SyncDynamicGoChange,
|
||||
new SceneMethods.SyncDynamicGoChangeArgs4229938
|
||||
{
|
||||
dynamicGoId = args.dyId,
|
||||
active = args.active,
|
||||
reason = args.reason,
|
||||
});
|
||||
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
/// <summary>One prop the caller wants placed. Used by the batch channel.</summary>
|
||||
internal readonly record struct GadgetSpawnSpec(
|
||||
int PathId, float X, float Y, float Z, float Yaw, ulong UniqueId, string Name);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the mandatory part of GadgetEntityInfo and books the instance in
|
||||
/// <see cref="WorldEntryState.SpawnedGadgets"/>. Shared by the single and batch channels.
|
||||
/// </summary>
|
||||
internal static (SceneMethods.GadgetEntityInfo Info, ulong Id) BuildGadgetEntityInfo(
|
||||
TcpSession session, WorldEntryState state, int pathId, float x, float y, float z, float facing,
|
||||
ulong uniqueId = 0UL, int iScale = 100,
|
||||
IReadOnlyList<(int MarkId, string Value)>? spoonParams = null,
|
||||
bool houseFurniture = false)
|
||||
{
|
||||
var name = GadgetCatalog4229938.TryFind(pathId, out var entry) ? entry.Name : "";
|
||||
if (string.IsNullOrEmpty(name))
|
||||
session.Log.Warn($"[GADGET] pathId {pathId} is not in GadgetCatalog.json, sending anyway");
|
||||
|
||||
ulong id;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
id = uniqueId != 0UL ? uniqueId : state.NextSpawnedGadgetId++;
|
||||
state.SpawnedGadgets[id] = new SpawnedGadgetRecord
|
||||
{
|
||||
InstanceId = id,
|
||||
PathId = pathId,
|
||||
Name = name,
|
||||
X = x,
|
||||
Y = y,
|
||||
Z = z,
|
||||
Yaw = facing,
|
||||
IsHouseFurniture = houseFurniture,
|
||||
};
|
||||
}
|
||||
|
||||
// Facing is a heading in degrees on our side; the wire carries it as the euler triple the
|
||||
// client's transform expects, with Y as the yaw axis.
|
||||
var info = new SceneMethods.GadgetEntityInfo
|
||||
{
|
||||
InstanceId = id,
|
||||
Position = new() { X = x, Y = y, Z = z },
|
||||
Facing = new() { X = 0f, Y = facing, Z = 0f },
|
||||
NavId = 0,
|
||||
ForceLod0 = true,
|
||||
IsTask = false,
|
||||
Pack = new SceneMethods.PackedGadgetInfo
|
||||
{
|
||||
posX = x,
|
||||
posY = y,
|
||||
posZ = z,
|
||||
eulerX = 0f,
|
||||
eulerY = facing,
|
||||
eulerZ = 0f,
|
||||
iScale = iScale,
|
||||
uniqueId = uniqueId != 0UL ? uniqueId : id,
|
||||
pathId = pathId,
|
||||
spoonSpecialList = BuildSpoonSpecialList(session, pathId, spoonParams),
|
||||
delayDestroy = false,
|
||||
isStatic = true,
|
||||
},
|
||||
};
|
||||
|
||||
return (info, id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-prefab spoon parameters (<c>PackedGadgetInfo.spoonSpecialList</c>). Most props ship it
|
||||
/// null — a frida dump of 161 live instances found only the SpoonSlot doors and
|
||||
/// MiniGameMachine_Bee carrying anything (markId 31511937 = "True", which is what arms the
|
||||
/// mini-game). Explicit params win; otherwise we look the prefab up in
|
||||
/// <c>world.content.gadgetSpoonParams.byPathId</c>. Null came a full stop before this existed,
|
||||
/// so nothing else can regress by adding it.
|
||||
/// </summary>
|
||||
internal static List<SceneMethods.PackedGadgetSpecialParam>? BuildSpoonSpecialList(
|
||||
TcpSession session, int pathId, IReadOnlyList<(int MarkId, string Value)>? explicitParams)
|
||||
{
|
||||
if (explicitParams is { Count: > 0 })
|
||||
return explicitParams
|
||||
.Select(p => new SceneMethods.PackedGadgetSpecialParam { markId = p.MarkId, value = p.Value })
|
||||
.ToList();
|
||||
|
||||
var cfg = PrivateServerConfigStore.Current.World.Content.GadgetSpoonParams;
|
||||
if (!cfg.Enabled)
|
||||
return null;
|
||||
if (!cfg.ByPathId.TryGetValue(pathId.ToString(), out var entries) || entries is null || entries.Count == 0)
|
||||
return null;
|
||||
|
||||
var list = entries
|
||||
.Select(e => new SceneMethods.PackedGadgetSpecialParam { markId = e.MarkId, value = e.Value })
|
||||
.ToList();
|
||||
session.Log.Info($"[GADGET] spoon params pathId={pathId} count={list.Count} " +
|
||||
$"[{string.Join(",", list.Select(p => $"{p.markId}={p.value}"))}]");
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places a whole batch with SyncGadgetAddList(68925813) — one push, N entities. This is the
|
||||
/// channel AnantaGymLoader.exe drives by injection; it exists here so the server can try it
|
||||
/// without the loader (see <see cref="GadgetSendChannel.AddList"/>).
|
||||
/// </summary>
|
||||
internal static async Task<(bool Ok, string Message, int Count)> SpawnGadgetListAsync(
|
||||
TcpSession session, WorldEntryState state, IReadOnlyList<GadgetSpawnSpec> specs, int iScale = 100,
|
||||
bool houseFurniture = false)
|
||||
{
|
||||
if (specs.Count == 0)
|
||||
return (false, "no specs", 0);
|
||||
|
||||
var infos = new List<SceneMethods.GadgetEntityInfo>(specs.Count);
|
||||
foreach (var spec in specs)
|
||||
{
|
||||
if (spec.PathId == 0)
|
||||
continue;
|
||||
var (info, _) = BuildGadgetEntityInfo(
|
||||
session, state, spec.PathId, spec.X, spec.Y, spec.Z, spec.Yaw, spec.UniqueId, iScale,
|
||||
spoonParams: null, houseFurniture: houseFurniture);
|
||||
infos.Add(info);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await session.NotifyAsync(
|
||||
MethodId.SyncGadgetAddList,
|
||||
UxSerializer.Serialize(new SceneMethods.SyncGadgetAddListArgs4229938 { infos = infos }),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"send failed: {ex.Message}", 0);
|
||||
}
|
||||
|
||||
session.Log.Info($"[GADGET] addlist count={infos.Count} iScale={iScale} " +
|
||||
$"ids=[{string.Join(",", infos.Select(i => i.InstanceId).Take(6))}...]");
|
||||
return (true, $"已批量放置 {infos.Count} 个物件", infos.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places one Gadget prefab into the world in front of the player and returns its instance id.
|
||||
///
|
||||
/// Only the mandatory part of GadgetEntityInfo is filled: instance id, transform, and the
|
||||
/// packed placement (pathId + same transform + a fresh uniqueId). Every authored sub-module —
|
||||
/// doors, drill shelves, lifts, occupants — is left null, which the client reads as "this prop
|
||||
/// has none". ForceLod0 keeps a freshly spawned prop from being culled to a billboard.
|
||||
/// </summary>
|
||||
/// <param name="uniqueId">
|
||||
/// Non-zero = reuse that instance id instead of minting one. Gym equipment passes the real blob
|
||||
/// uid captured from the client, so the entity the server places is the same instance the client
|
||||
/// would have built itself (its prefab lookup and any blob-side state key off that uid).
|
||||
/// </param>
|
||||
internal static async Task<(bool Ok, string Message, ulong Id)> SpawnGadgetAsync(
|
||||
TcpSession session, WorldEntryState state, int pathId, float x, float y, float z, float facing,
|
||||
GadgetSendChannel channel = GadgetSendChannel.Add, ulong uniqueId = 0UL, int iScale = 100)
|
||||
{
|
||||
if (pathId == 0)
|
||||
return (false, "pathId 0 is not a gadget", 0UL);
|
||||
|
||||
var (info, id) = BuildGadgetEntityInfo(session, state, pathId, x, y, z, facing, uniqueId, iScale);
|
||||
|
||||
try
|
||||
{
|
||||
if (channel is GadgetSendChannel.Add or GadgetSendChannel.Both)
|
||||
await session.NotifyAsync(MethodId.SyncGadgetAdd, UxSerializer.Serialize(info), CancellationToken.None);
|
||||
|
||||
if (channel is GadgetSendChannel.Aoi or GadgetSendChannel.Both)
|
||||
await session.NotifyAsync(
|
||||
MethodId.SyncGadgetAOIAddAndRemove,
|
||||
UxSerializer.Serialize(new SceneMethods.SyncGadgetAOIAddAndRemoveArgs4229938
|
||||
{
|
||||
addInfos = [info],
|
||||
reason = AoiAddAndRemoveReasonAoiMove,
|
||||
}),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
state.SpawnedGadgets.Remove(id);
|
||||
}
|
||||
return (false, $"send failed: {ex.Message}", 0UL);
|
||||
}
|
||||
|
||||
var label = GadgetCatalog4229938.TryFind(pathId, out var done) ? done.Name : "";
|
||||
session.Log.Info($"[GADGET] add id={id} pathId={pathId} name={label} channel={channel} at ({x:F1},{y:F1},{z:F1}) facing={facing:F1}");
|
||||
return (true, $"已放置 {label}(pathId {pathId},{channel})", id);
|
||||
}
|
||||
|
||||
/// <summary>Removes every Gadget this panel placed, or just one when an id is given.</summary>
|
||||
/// <param name="pathIdFilter">
|
||||
/// 只撤某一种物件(按 pathId)。**这是撤离探针残留的正确姿势** —— 全清会把健身房器材 /
|
||||
/// PropRestock / 摊位补给一起抹掉,而它们各自管线有自己的 Sent 记录,不一定会立刻补回来。
|
||||
/// (2026-10-03 随赛车移植:赛车门「过点即撤」要按 pathId 批量撤。)
|
||||
/// </param>
|
||||
/// <param name="onlyIds">
|
||||
/// ★ 只撤这一批 id(比 <paramref name="pathIdFilter"/> 更细)。
|
||||
/// 赛车门一局 52 个门**同一个 pathId**,按 pathId 撤会把还没跑到的一起撤掉,
|
||||
/// 所以「过点即撤」必须用它。
|
||||
/// </param>
|
||||
internal static async Task<(bool Ok, string Message, int Count)> RemoveGadgetsAsync(
|
||||
TcpSession session, WorldEntryState state, ulong? onlyId = null, int pathIdFilter = 0,
|
||||
IReadOnlyList<ulong>? onlyIds = null)
|
||||
{
|
||||
List<ulong> ids;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (onlyIds is { Count: > 0 })
|
||||
ids = onlyIds.Where(id => state.SpawnedGadgets.ContainsKey(id)).ToList();
|
||||
else
|
||||
ids = onlyId.HasValue
|
||||
? (state.SpawnedGadgets.ContainsKey(onlyId.Value) ? [onlyId.Value] : [])
|
||||
: pathIdFilter != 0
|
||||
? state.SpawnedGadgets.Values.Where(r => r.PathId == pathIdFilter)
|
||||
.Select(r => r.InstanceId).ToList()
|
||||
: state.SpawnedGadgets.Keys.ToList();
|
||||
}
|
||||
|
||||
if (ids.Count == 0)
|
||||
return (true, "没有可移除的物件", 0);
|
||||
|
||||
await session.NotifyAsync(
|
||||
MethodId.SyncGadgetRemoveList,
|
||||
UxSerializer.Serialize(new SceneMethods.SyncGadgetRemoveListArgs4229938
|
||||
{
|
||||
ids = ids,
|
||||
reason = GadgetDisposeReasonGm,
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
foreach (var id in ids)
|
||||
state.SpawnedGadgets.Remove(id);
|
||||
}
|
||||
|
||||
session.Log.Info($"[GADGET] remove count={ids.Count} ids=[{string.Join(",", ids)}]");
|
||||
return (true, $"已移除 {ids.Count} 个物件", ids.Count);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user