using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// Private-server GM surface (build 4229938): the in-game GM console sends these /// C2S invokes. Strategy is return-real-ids-first: the client spawns GM entities locally /// (GM invoke call sites ignore the return), the server records the ids so the admin /// panel can manage them. If a command proves to wait for a server broadcast, the push /// (SyncLogicVehicleEnter etc.) gets added for that command. /// internal sealed partial class GameRouter { private static long _gmEntitySeq = 320000000000L; private static long _gmEnemySeq = 330000000000L; [Handler(MethodId.GmSpawnVehicle, HandlerPacketKind.Invoke)] private async Task GmSpawnVehicle(Connection conn, UxRpcMessage msg) { var args = msg.GetArgs(); var entityId = (ulong)Interlocked.Increment(ref _gmEntitySeq); // Use the 9-version direct spawn path so the vehicle actually materializes // beside the player and is drivable. Falls back to a plain id return if the // spawn path rejects the config (unknown / model-less vehicles). var nearPos = new Vec3(args.Position.X, args.Position.Y, args.Position.Z); var result = await SpawnDirectAsync(conn.Session, args.TemplateId, nearPos, args.Facing, "gm-spawn"); if (result.Ok) { entityId = result.EntityId; conn.Log.Info($"[GM] spawn vehicle cfg={args.TemplateId} suit={args.SuitId} own={args.LoadOwnVehicle} spoon='{args.SpoonName}' at=({args.Position.X:F1},{args.Position.Y:F1},{args.Position.Z:F1}) -> entity={entityId}"); } else { conn.Log.Warn($"[GM] spawn vehicle cfg={args.TemplateId} rejected ({result.Message}); returning placeholder entity={entityId}"); } await conn.ReturnAsync(msg, entityId); } [Handler(MethodId.GmAddEnemyWithPosition, HandlerPacketKind.Invoke)] private async Task GmAddEnemyWithPosition(Connection conn, UxRpcMessage msg) { var args = msg.GetArgs(); var at = new Vec3(args.Position.X, args.Position.Y, args.Position.Z); var entityId = await SpawnGmEnemyAsync(conn, args.EnemyId, at, nameof(GmAddEnemyWithPosition)); await conn.ReturnAsync(msg, entityId); } [Handler(MethodId.GmAddEnemy, HandlerPacketKind.Invoke)] private async Task GmAddEnemy(Connection conn, UxRpcMessage msg) { var args = msg.GetArgs(); var entityId = await SpawnGmEnemyAsync(conn, args.EnemyId, null, nameof(GmAddEnemy)); await conn.ReturnAsync(msg, entityId); } [Handler(MethodId.GmAddEnemyByPlayer, HandlerPacketKind.Invoke)] private async Task GmAddEnemyByPlayer(Connection conn, UxRpcMessage msg) { var args = msg.GetArgs(); var entityId = await SpawnGmEnemyAsync(conn, args.EnemyId, null, nameof(GmAddEnemyByPlayer)); await conn.ReturnAsync(msg, entityId); } [Handler(MethodId.GmAddNpc, HandlerPacketKind.Invoke)] private async Task GmAddNpc(Connection conn, UxRpcMessage msg) { var args = msg.GetArgs(); var entityId = await SpawnGmNpcAsync(conn, args.Npcid, nameof(GmAddNpc)); await conn.ReturnAsync(msg, entityId); } /// /// Materialises a civilian / friendly NPC for a GM add command. /// /// GmAddNpc carries only two uint32s, so unlike the enemy commands it survives the client /// console bridge intact and is currently the only usable C2S GM spawn command typed in /// ALT+F1 (e.g. "GmAddNpc 4065053 0"). /// private async Task SpawnGmNpcAsync(Connection conn, uint npcId, string tag) { if (npcId == 0) { conn.Log.Warn($"[GM] {tag} got npcId=0 — nothing spawned"); return (ulong)Interlocked.Increment(ref _gmEnemySeq); } var state = GetStateIfExists(conn.Session); var hasTransform = state is not null && state.HasLastReportedPlayerTransform; var basePos = hasTransform ? state!.LastReportedPlayerPosition : new Vec3(0, 0, 0); var facing = hasTransform ? state!.LastReportedPlayerRotation.Y : 0f; // Same placement rule as SpawnGmEnemyAsync: a few units in front of the player. // Y must stay at the player's own height: the client takes the server-supplied // position verbatim (no ground snapping), so any +1 offset leaves the unit hovering. // ⛑ facing 是度数,必须先转弧度(2026-09-26 修,详见 SpawnAgentAsync 里的同一处注释)。 var yaw = facing * (MathF.PI / 180f); var x = basePos.X + (float)Math.Sin(yaw) * 3.5f; var z = basePos.Z + (float)Math.Cos(yaw) * 3.5f; var y = basePos.Y; var result = await GameAdminBridge.SpawnAgentAsync(npcId, x, y, z, facing, hostile: false); conn.Log.Info($"[GM] {tag} npc={npcId} ok={result.Ok} uid={result.Uid} at=({x:F1},{y:F1},{z:F1}) {result.Message}"); return result.Ok && result.Uid != 0 ? result.Uid : (ulong)Interlocked.Increment(ref _gmEnemySeq); } /// /// Materialises an enemy for a GM add command. /// /// The client forwards AddEnemy* to the server as a C2S invoke, so nothing appears unless /// the server actually spawns. It also mangles the id on the way back (the panel asks for /// 40100010 and the C2S arrives with 40900579), so the id the operator typed is preferred /// over the forwarded one whenever it is still fresh. /// private async Task SpawnGmEnemyAsync(Connection conn, uint forwardedId, Vec3? explicitPos, string tag) { var state = GetStateIfExists(conn.Session); var hasTransform = state is not null && state.HasLastReportedPlayerTransform; var basePos = explicitPos ?? (hasTransform ? state!.LastReportedPlayerPosition : new Vec3(0, 0, 0)); var facing = hasTransform ? state!.LastReportedPlayerRotation.Y : 0f; // The operator's intent wins over the mangled forwarded id. var id = GameAdminBridge.TakePendingGmEnemyId(); if (id == 0) id = forwardedId; if (id == 0) { conn.Log.Warn($"[GM] {tag} has no enemy id (forwarded=0, no pending intent) — nothing spawned"); return (ulong)Interlocked.Increment(ref _gmEnemySeq); } // Drop it a few units in front of the player instead of inside them. // ⛑ facing 是度数,必须先转弧度(2026-09-26 修,详见 SpawnAgentAsync 里的同一处注释)。 var yaw = facing * (MathF.PI / 180f); var x = explicitPos is null ? basePos.X + (float)Math.Sin(yaw) * 3.5f : basePos.X; var z = explicitPos is null ? basePos.Z + (float)Math.Cos(yaw) * 3.5f : basePos.Z; var y = basePos.Y; var result = await GameAdminBridge.SpawnAgentAsync(id, x, y, z, facing, hostile: true); conn.Log.Info($"[GM] {tag} forwarded={forwardedId} effective={id} ok={result.Ok} uid={result.Uid} at=({x:F1},{y:F1},{z:F1}) {result.Message}"); return result.Ok && result.Uid != 0 ? result.Uid : (ulong)Interlocked.Increment(ref _gmEnemySeq); } [Handler(MethodId.GmTeleportXYZ, HandlerPacketKind.Invoke)] private async Task GmTeleportXYZ(Connection conn, UxRpcMessage msg) { var args = msg.GetArgs(); conn.Log.Info($"[GM] teleport to=({args.X:F1},{args.Y:F1},{args.Z:F1}) facing={args.Facing:F1}"); await TeleportPlayerInstantAsync(msg.Context, args.X, args.Y, args.Z, args.Facing, "gm-teleport-xyz"); await conn.ReturnEmptyOkAsync(msg); } }