diff --git a/dev/GameServer.Dockerfile b/dev/GameServer.Dockerfile deleted file mode 100644 index 4db7fee6..00000000 --- a/dev/GameServer.Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build -WORKDIR /app -COPY ./src/server/ . -COPY ./src/Directory.Build.props Directory.Build.props -RUN dotnet publish ./GameServer/GameServer.csproj -c Release -o bin - -FROM mcr.microsoft.com/dotnet/runtime:7.0 -WORKDIR /app -COPY ./dev/dbc ./dbc -COPY --from=build /app/bin . -COPY ./dev/configuration.json ./configuration.json -ENTRYPOINT ["./GameServer"] \ No newline at end of file diff --git a/dev/README.md b/dev/README.md index 368cb411..d465b606 100644 --- a/dev/README.md +++ b/dev/README.md @@ -12,7 +12,8 @@ This folder contain docker images for development purpose # How to start server 1) `docker-compose up -d realm` 2) Wait 5 seconds -3) `docker-compose up -d game` +3) `docker-compose up -d cluster` +4) `docker-compose up -d world` # How to stop server docker-compose down diff --git a/dev/configuration.distributed.json b/dev/configuration.distributed.json index 36c73688..33c8eec4 100644 --- a/dev/configuration.distributed.json +++ b/dev/configuration.distributed.json @@ -12,6 +12,24 @@ "CharacterDatabase": "root;rootpass;mysql;3306;mangosVBcharacters;MySQL", "WorldDatabase": "root;rootpass;mysql;3306;mangosVBworld;MySQL" }, + "Federation": { + "Enabled": false, + "LocalClusterId": 1, + "LocalDisplayTag": "MS", + "ListenAddress": "0.0.0.0", + "ListenPort": 50101, + "MarkerMode": "ClientPreference", + "Peers": [] + }, + "Supervisor": { + "Enabled": false, + "HeartbeatIntervalMs": 5000, + "StaleAfterMissed": 3, + "DeadAfterMissed": 5, + "RespawnBackoffStepMs": 2000, + "RespawnBackoffMaxMs": 60000, + "Worlds": [] + }, "World": { "ClusterConnectHost": "cluster", "ClusterConnectPort": 50001, diff --git a/dev/configuration.json b/dev/configuration.json index 247575cd..9848c719 100644 --- a/dev/configuration.json +++ b/dev/configuration.json @@ -12,6 +12,24 @@ "CharacterDatabase": "root;rootpass;mysql;3306;mangosVBcharacters;MySQL", "WorldDatabase": "root;rootpass;mysql;3306;mangosVBworld;MySQL" }, + "Federation": { + "Enabled": false, + "LocalClusterId": 1, + "LocalDisplayTag": "MS", + "ListenAddress": "0.0.0.0", + "ListenPort": 50101, + "MarkerMode": "ClientPreference", + "Peers": [] + }, + "Supervisor": { + "Enabled": false, + "HeartbeatIntervalMs": 5000, + "StaleAfterMissed": 3, + "DeadAfterMissed": 5, + "RespawnBackoffStepMs": 2000, + "RespawnBackoffMaxMs": 60000, + "Worlds": [] + }, "World": { "ClusterConnectHost": "127.0.0.1", "ClusterConnectPort": 50001, diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index f1d385bc..df10ccad 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -12,23 +12,7 @@ services: networks: - mangosSharp - # Monolithic mode (cluster + world in one process) - game: - image: mangos/gameserver - container_name: gameserver - depends_on: - - mysql - ports: - - 8085:8085 - build: - context: .. - dockerfile: ./dev/GameServer.Dockerfile - networks: - - mangosSharp - profiles: - - monolithic - - # Distributed mode: cluster accepts game clients, routes to world servers + # Cluster gateway: accepts game clients, routes packets to world servers cluster: image: mangos/worldcluster container_name: worldcluster @@ -42,10 +26,8 @@ services: dockerfile: ./dev/WorldCluster.Dockerfile networks: - mangosSharp - profiles: - - distributed - # Distributed mode: world server connects to cluster via IPC + # World server: connects to cluster via IPC, runs game logic world: image: mangos/worldserver container_name: worldserver @@ -57,8 +39,6 @@ services: dockerfile: ./dev/WorldServer.Dockerfile networks: - mangosSharp - profiles: - - distributed mysql: image: mysql:8.0 diff --git a/sql/Updates/Accounts/Rel21_02_002.sql b/sql/Updates/Accounts/Rel21_02_002.sql new file mode 100644 index 00000000..c2cca8d7 --- /dev/null +++ b/sql/Updates/Accounts/Rel21_02_002.sql @@ -0,0 +1,57 @@ +-- +-- Federation support: peer-cluster admin endpoints + per-realm display +-- markers + per-account opt-in for showing those markers. +-- +-- Applied as part of the world-cluster proxy refactor (PR #4). +-- Idempotent: safe to re-run after a partial apply. +-- + +-- Realmlist: cluster identity, federation listener address, and the short +-- tag that's prepended/appended to player names when chat or roster info +-- is replicated cross-realm. +ALTER TABLE `realmlist` + ADD COLUMN IF NOT EXISTS `clusterId` INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT 'Federation cluster id; 0 disables federation for this realm', + ADD COLUMN IF NOT EXISTS `clusterAdminEndpoint` VARCHAR(128) NOT NULL DEFAULT '' + COMMENT 'host:port of cluster federation listener; empty disables peer admin/chat', + ADD COLUMN IF NOT EXISTS `displayTag` VARCHAR(8) NOT NULL DEFAULT '' + COMMENT 'Short marker shown for cross-realm chat and player frames', + ADD COLUMN IF NOT EXISTS `markerPosition` ENUM('prefix','suffix','none') NOT NULL DEFAULT 'prefix' + COMMENT 'Where to attach displayTag when rendering foreign-realm names'; + +-- Per-account toggle. When a player flips this off they stop seeing the +-- [tag] markers on cross-realm chat and player frames. Whispers always +-- carry the marker regardless so reply targeting still works. +ALTER TABLE `account` + ADD COLUMN IF NOT EXISTS `federation_show_markers` TINYINT(1) NOT NULL DEFAULT 1 + COMMENT 'Show [tag] on cross-realm players/chat for this account'; + +-- Cluster-local mirror of federated groups. The leader's cluster owns +-- the authoritative copy; peers replicate enough to draw the party UI. +CREATE TABLE IF NOT EXISTS `federation_group` ( + `groupId` BIGINT UNSIGNED NOT NULL, + `leaderRealmId` INT UNSIGNED NOT NULL, + `leaderGuid` BIGINT UNSIGNED NOT NULL, + `groupType` TINYINT UNSIGNED NOT NULL COMMENT '0=party, 1=raid', + `shardKey` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Reserved for Phase B co-location', + `createdAt` DATETIME NOT NULL, + `updatedAt` DATETIME NOT NULL, + PRIMARY KEY (`groupId`, `leaderRealmId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `federation_group_member` ( + `groupId` BIGINT UNSIGNED NOT NULL, + `leaderRealmId` INT UNSIGNED NOT NULL, + `memberRealmId` INT UNSIGNED NOT NULL, + `memberGuid` BIGINT UNSIGNED NOT NULL, + `memberName` VARCHAR(12) NOT NULL, + `role` TINYINT UNSIGNED NOT NULL DEFAULT 0 + COMMENT 'Bitfield: 1=leader, 2=assist, 4=mainTank, 8=mainAssist', + PRIMARY KEY (`groupId`, `leaderRealmId`, `memberRealmId`, `memberGuid`), + KEY `idx_member` (`memberRealmId`, `memberGuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Bump db_version so DbVersionChecker accepts the new schema. +INSERT INTO `db_version`(`version`,`structure`,`content`,`description`,`comment`) +VALUES (21,2,2,'Federation_columns','PR #4 world-cluster proxy: peer admin endpoints + cross-realm markers') +ON DUPLICATE KEY UPDATE `description`=VALUES(`description`), `comment`=VALUES(`comment`); diff --git a/src/server/GameServer/GameModule.cs b/src/server/GameServer/GameModule.cs deleted file mode 100644 index 19caf063..00000000 --- a/src/server/GameServer/GameModule.cs +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -using Autofac; -using GameServer.Handlers; -using GameServer.Network; -using GameServer.Requests; -using GameServer.Services; -using Mangos.Tcp; - -namespace GameServer; - -internal sealed class GameModule : Module -{ - protected override void Load(ContainerBuilder builder) - { - builder.RegisterType().As().InstancePerLifetimeScope(); - builder.RegisterType().As().InstancePerLifetimeScope(); - - RegisterHandlers(builder); - } - - private void RegisterHandlers(ContainerBuilder builder) - { - builder.RegisterType().InstancePerLifetimeScope(); - builder.RegisterType>().As().InstancePerLifetimeScope(); - } -} diff --git a/src/server/GameServer/GameServer.csproj b/src/server/GameServer/GameServer.csproj deleted file mode 100644 index 4c130c88..00000000 --- a/src/server/GameServer/GameServer.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net9.0 - Exe - True - enable - enable - MangosCS.ico - - - - - - - - - - - - - - - - diff --git a/src/server/GameServer/Handlers/CMSG_PING_Handler.cs b/src/server/GameServer/Handlers/CMSG_PING_Handler.cs deleted file mode 100644 index f8c277e3..00000000 --- a/src/server/GameServer/Handlers/CMSG_PING_Handler.cs +++ /dev/null @@ -1,36 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -using GameServer.Network; -using GameServer.Requests; -using GameServer.Responses; - -namespace GameServer.Handlers; - -internal sealed class CMSG_PING_Handler : IHandler -{ - public Task ExectueAsync(CMSG_PING request) - { - var response = new SMSG_PONG - { - Payload = request.Payload - }; - - return HandlerResult.FromTask(response); - } -} diff --git a/src/server/GameServer/MangosCS.ico b/src/server/GameServer/MangosCS.ico deleted file mode 100644 index 1db0acf8..00000000 Binary files a/src/server/GameServer/MangosCS.ico and /dev/null differ diff --git a/src/server/GameServer/Network/GameTcpConnection.cs b/src/server/GameServer/Network/GameTcpConnection.cs deleted file mode 100644 index 6be66588..00000000 --- a/src/server/GameServer/Network/GameTcpConnection.cs +++ /dev/null @@ -1,192 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -using System.Buffers; -using System.Buffers.Binary; -using System.IO; -using System.Net.Sockets; -using GameServer.Responses; -using Mangos.Cluster.Globals; -using Mangos.Cluster.Network; -using Mangos.Tcp; - -namespace GameServer.Network; - -internal sealed class GameTcpConnection : ITcpConnection -{ - private const int MAX_PACKET_LENGTH = 10000; - - private readonly ClientClass legacyClientClass; - private readonly IHandlerDispatcher[] dispatchers; - - private readonly MemoryPool memoryPool = MemoryPool.Shared; - - public GameTcpConnection(ClientClass legacyClientClass, IEnumerable dispatchers) - { - this.legacyClientClass = legacyClientClass; - - this.dispatchers = dispatchers.ToArray(); - } - - public async Task ExecuteAsync(Socket socket, CancellationToken cancellationToken) - { - legacyClientClass.Socket = socket; - await legacyClientClass.OnConnectAsync(); - - while (!cancellationToken.IsCancellationRequested) - { - await WaitForNextPacket(socket, cancellationToken); - await HandlePacketAsync(socket, cancellationToken); - } - } - - private async Task HandlePacketAsync(Socket socket, CancellationToken cancellationToken) - { - using var memoryOwner = memoryPool.Rent(MAX_PACKET_LENGTH); - var header = await ReadPacketHeaderAsync(socket, memoryOwner.Memory, cancellationToken); - var body = await ReadPacketBodyAsync(socket, memoryOwner.Memory, cancellationToken); - - var opcode = (Opcodes)BinaryPrimitives.ReadUInt32LittleEndian(header.Span.Slice(2)); - - var dispatcher = dispatchers.FirstOrDefault(x => x.Opcode == opcode); - if (dispatcher != null) - { - await ExecuteHandlerAsync(dispatcher, body, socket, cancellationToken); - } - else - { - ExecuteLegacyHandler(memoryOwner.Memory.Slice(0, header.Length + body.Length)); - } - } - - private async Task ExecuteHandlerAsync(IHandlerDispatcher dispatcher, Memory body, Socket socket, CancellationToken cancellationToken) - { - using var result = await dispatcher.ExectueAsync(new PacketReader(body)); - using var memoryOwner = memoryPool.Rent(MAX_PACKET_LENGTH); - foreach (var response in result.GetResponseMessages()) - { - await SendAsync(socket, memoryOwner.Memory, response, cancellationToken); - } - } - - private void ExecuteLegacyHandler(ReadOnlyMemory packet) - { - var legacyPacket = new PacketClass(packet.ToArray()); - legacyClientClass.OnPacket(legacyPacket); - } - - private void DecodePacketHeader(Span data) - { - if (!legacyClientClass.Client.PacketEncryption.IsEncryptionEnabled) - { - return; - } - - var key = legacyClientClass.Client.PacketEncryption.Key; - var hash = legacyClientClass.Client.PacketEncryption.Hash; - for (var i = 0; i < 6; i++) - { - var tmp = data[i]; - data[i] = (byte)(hash[key[1]] ^ (256 + data[i] - key[0]) % 256); - key[0] = tmp; - key[1] = (byte)((key[1] + 1) % 40); - } - } - - public void EncodePacketHeader(Span data) - { - if (!legacyClientClass.Client.PacketEncryption.IsEncryptionEnabled) - { - return; - } - - var key = legacyClientClass.Client.PacketEncryption.Key; - var hash = legacyClientClass.Client.PacketEncryption.Hash; - for (var i = 0; i < 4; i++) - { - data[i] = (byte)(((hash[key[3]] ^ data[i]) + key[2]) % 256); - key[2] = data[i]; - key[3] = (byte)((key[3] + 1) % 40); - } - } - - private async ValueTask WaitForNextPacket(Socket socket, CancellationToken cancellationToken) - { - await socket.ReceiveAsync(Array.Empty(), cancellationToken); - } - - private async ValueTask> ReadPacketHeaderAsync(Socket socket, Memory buffer, CancellationToken cancellationToken) - { - var header = buffer.Slice(0, 6); - await ReadAsync(socket, header, cancellationToken); - DecodePacketHeader(header.Span); - return header; - } - - private async ValueTask> ReadPacketBodyAsync(Socket socket, Memory buffer, CancellationToken cancellationToken) - { - var length = BinaryPrimitives.ReadUInt16BigEndian(buffer.Span) - 4; - var body = buffer.Slice(6, length); - await ReadAsync(socket, body, cancellationToken); - return body; - } - - private async ValueTask SendAsync(Socket socket, Memory buffer, IResponseMessage response, CancellationToken cancellationToken) - { - var packetWriter = new PacketWriter(buffer, response.Opcode); - response.Write(packetWriter); - var packet = packetWriter.ToPacket(); - EncodePacketHeader(packet.Span); - await SendAsync(socket, packet, cancellationToken); - } - - private async ValueTask ReadAsync(Socket socket, Memory buffer, CancellationToken cancellationToken) - { - if (buffer.Length == 0) - { - return; - } - - var totalRead = 0; - while (totalRead < buffer.Length) - { - var bytesRead = await socket.ReceiveAsync(buffer.Slice(totalRead), cancellationToken); - if (bytesRead == 0) - { - throw new IOException("Connection closed by remote host during read"); - } - - totalRead += bytesRead; - } - } - - private async ValueTask SendAsync(Socket socket, ReadOnlyMemory buffer, CancellationToken cancellationToken) - { - var totalSent = 0; - while (totalSent < buffer.Length) - { - var bytesSent = await socket.SendAsync(buffer.Slice(totalSent), cancellationToken); - if (bytesSent == 0) - { - throw new IOException("Connection closed by remote host during send"); - } - - totalSent += bytesSent; - } - } -} diff --git a/src/server/GameServer/Network/HandlerResult.cs b/src/server/GameServer/Network/HandlerResult.cs deleted file mode 100644 index d45951c0..00000000 --- a/src/server/GameServer/Network/HandlerResult.cs +++ /dev/null @@ -1,56 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -using System.Buffers; -using GameServer.Responses; - -namespace GameServer.Network; - -internal sealed class HandlerResult : IDisposable -{ - private readonly IResponseMessage[] messages; - private readonly int length; - - private HandlerResult(IResponseMessage[] messages, int length) - { - this.messages = messages; - this.length = length; - } - - public static HandlerResult From(IResponseMessage responseMessage) - { - var memoryOwner = ArrayPool.Shared.Rent(1); - memoryOwner[0] = responseMessage; - return new HandlerResult(memoryOwner, 1); - } - - public static Task FromTask(IResponseMessage responseMessage) - { - return Task.FromResult(From(responseMessage)); - } - - public IEnumerable GetResponseMessages() - { - return messages.Take(length); - } - - public void Dispose() - { - ArrayPool.Shared.Return(messages); - } -} diff --git a/src/server/GameServer/Network/Opcodes.cs b/src/server/GameServer/Network/Opcodes.cs deleted file mode 100644 index 13fc1a44..00000000 --- a/src/server/GameServer/Network/Opcodes.cs +++ /dev/null @@ -1,859 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -namespace GameServer.Network; - -public enum Opcodes : ushort -{ - MSG_NULL_ACTION = 0x0, - CMSG_BOOTME = 0x1, - CMSG_DBLOOKUP = 0x2, - SMSG_DBLOOKUP = 0x3, - CMSG_QUERY_OBJECT_POSITION = 0x4, - SMSG_QUERY_OBJECT_POSITION = 0x5, - CMSG_QUERY_OBJECT_ROTATION = 0x6, - SMSG_QUERY_OBJECT_ROTATION = 0x7, - CMSG_WORLD_TELEPORT = 0x8, - CMSG_TELEPORT_TO_UNIT = 0x9, - CMSG_ZONE_MAP = 0xA, - SMSG_ZONE_MAP = 0xB, - CMSG_DEBUG_CHANGECELLZONE = 0xC, - CMSG_EMBLAZON_TABARD_OBSOLETE = 0xD, - CMSG_UNEMBLAZON_TABARD_OBSOLETE = 0xE, - CMSG_RECHARGE = 0xF, - CMSG_LEARN_SPELL = 0x10, - CMSG_CREATEMONSTER = 0x11, - CMSG_DESTROYMONSTER = 0x12, - CMSG_CREATEITEM = 0x13, - CMSG_CREATEGAMEOBJECT = 0x14, - CMSG_MAKEMONSTERATTACKME_OBSOLETE = 0x15, - CMSG_MAKEMONSTERATTACKGUID = 0x16, - CMSG_ENABLEDEBUGCOMBATLOGGING_OBSOLETE = 0x17, - CMSG_FORCEACTION = 0x18, - CMSG_FORCEACTIONONOTHER = 0x19, - CMSG_FORCEACTIONSHOW = 0x1A, - SMSG_FORCEACTIONSHOW = 0x1B, - SMSG_ATTACKERSTATEUPDATEDEBUGINFO_OBSOLETE = 0x1C, - SMSG_DEBUGINFOSPELL_OBSOLETE = 0x1D, - SMSG_DEBUGINFOSPELLMISS_OBSOLETE = 0x1E, - SMSG_DEBUG_PLAYER_RANGE_OBSOLETE = 0x1F, - CMSG_UNDRESSPLAYER = 0x20, - CMSG_BEASTMASTER = 0x21, - CMSG_GODMODE = 0x22, - SMSG_GODMODE = 0x23, - CMSG_CHEAT_SETMONEY = 0x24, - CMSG_LEVEL_CHEAT = 0x25, - CMSG_PET_LEVEL_CHEAT = 0x26, - CMSG_LEVELUP_CHEAT_OBSOLETE = 0x27, - CMSG_COOLDOWN_CHEAT = 0x28, - CMSG_USE_SKILL_CHEAT = 0x29, - CMSG_FLAG_QUEST = 0x2A, - CMSG_FLAG_QUEST_FINISH = 0x2B, - CMSG_CLEAR_QUEST = 0x2C, - CMSG_SEND_EVENT = 0x2D, - CMSG_DEBUG_AISTATE = 0x2E, - SMSG_DEBUG_AISTATE = 0x2F, - CMSG_DISABLE_PVP_CHEAT = 0x30, - CMSG_ADVANCE_SPAWN_TIME = 0x31, - CMSG_PVP_PORT_OBSOLETE = 0x32, - CMSG_AUTH_SRP6_BEGIN = 0x33, - CMSG_AUTH_SRP6_PROOF = 0x34, - CMSG_AUTH_SRP6_RECODE = 0x35, - CMSG_CHAR_CREATE = 0x36, - CMSG_CHAR_ENUM = 0x37, - CMSG_CHAR_DELETE = 0x38, - SMSG_AUTH_SRP6_RESPONSE = 0x39, - SMSG_CHAR_CREATE = 0x3A, - SMSG_CHAR_ENUM = 0x3B, - SMSG_CHAR_DELETE = 0x3C, - CMSG_PLAYER_LOGIN = 0x3D, - SMSG_NEW_WORLD = 0x3E, - SMSG_TRANSFER_PENDING = 0x3F, - SMSG_TRANSFER_ABORTED = 0x40, - SMSG_CHARACTER_LOGIN_FAILED = 0x41, - SMSG_LOGIN_SETTIMESPEED = 0x42, - SMSG_GAMETIME_UPDATE = 0x43, - CMSG_GAMETIME_SET = 0x44, - SMSG_GAMETIME_SET = 0x45, - CMSG_GAMESPEED_SET = 0x46, - SMSG_GAMESPEED_SET = 0x47, - CMSG_SERVERTIME = 0x48, - SMSG_SERVERTIME = 0x49, - CMSG_PLAYER_LOGOUT = 0x4A, - CMSG_LOGOUT_REQUEST = 0x4B, - SMSG_LOGOUT_RESPONSE = 0x4C, - SMSG_LOGOUT_COMPLETE = 0x4D, - CMSG_LOGOUT_CANCEL = 0x4E, - SMSG_LOGOUT_CANCEL_ACK = 0x4F, - CMSG_NAME_QUERY = 0x50, - SMSG_NAME_QUERY_RESPONSE = 0x51, - CMSG_PET_NAME_QUERY = 0x52, - SMSG_PET_NAME_QUERY_RESPONSE = 0x53, - CMSG_GUILD_QUERY = 0x54, - SMSG_GUILD_QUERY_RESPONSE = 0x55, - CMSG_ITEM_QUERY_SINGLE = 0x56, - CMSG_ITEM_QUERY_MULTIPLE = 0x57, - SMSG_ITEM_QUERY_SINGLE_RESPONSE = 0x58, - SMSG_ITEM_QUERY_MULTIPLE_RESPONSE = 0x59, - CMSG_PAGE_TEXT_QUERY = 0x5A, - SMSG_PAGE_TEXT_QUERY_RESPONSE = 0x5B, - CMSG_QUEST_QUERY = 0x5C, - SMSG_QUEST_QUERY_RESPONSE = 0x5D, - CMSG_GAMEOBJECT_QUERY = 0x5E, - SMSG_GAMEOBJECT_QUERY_RESPONSE = 0x5F, - CMSG_CREATURE_QUERY = 0x60, - SMSG_CREATURE_QUERY_RESPONSE = 0x61, - CMSG_WHO = 0x62, - SMSG_WHO = 0x63, - CMSG_WHOIS = 0x64, - SMSG_WHOIS = 0x65, - CMSG_FRIEND_LIST = 0x66, - SMSG_FRIEND_LIST = 0x67, - SMSG_FRIEND_STATUS = 0x68, - CMSG_ADD_FRIEND = 0x69, - CMSG_DEL_FRIEND = 0x6A, - SMSG_IGNORE_LIST = 0x6B, - CMSG_ADD_IGNORE = 0x6C, - CMSG_DEL_IGNORE = 0x6D, - CMSG_GROUP_INVITE = 0x6E, - SMSG_GROUP_INVITE = 0x6F, - CMSG_GROUP_CANCEL = 0x70, - SMSG_GROUP_CANCEL = 0x71, - CMSG_GROUP_ACCEPT = 0x72, - CMSG_GROUP_DECLINE = 0x73, - SMSG_GROUP_DECLINE = 0x74, - CMSG_GROUP_UNINVITE = 0x75, - CMSG_GROUP_UNINVITE_GUID = 0x76, - SMSG_GROUP_UNINVITE = 0x77, - CMSG_GROUP_SET_LEADER = 0x78, - SMSG_GROUP_SET_LEADER = 0x79, - CMSG_LOOT_METHOD = 0x7A, - CMSG_GROUP_DISBAND = 0x7B, - SMSG_GROUP_DESTROYED = 0x7C, - SMSG_GROUP_LIST = 0x7D, - SMSG_PARTY_MEMBER_STATS = 0x7E, - SMSG_PARTY_COMMAND_RESULT = 0x7F, - UMSG_UPDATE_GROUP_MEMBERS = 0x80, - CMSG_GUILD_CREATE = 0x81, - CMSG_GUILD_INVITE = 0x82, - SMSG_GUILD_INVITE = 0x83, - CMSG_GUILD_ACCEPT = 0x84, - CMSG_GUILD_DECLINE = 0x85, - SMSG_GUILD_DECLINE = 0x86, - CMSG_GUILD_INFO = 0x87, - SMSG_GUILD_INFO = 0x88, - CMSG_GUILD_ROSTER = 0x89, - SMSG_GUILD_ROSTER = 0x8A, - CMSG_GUILD_PROMOTE = 0x8B, - CMSG_GUILD_DEMOTE = 0x8C, - CMSG_GUILD_LEAVE = 0x8D, - CMSG_GUILD_REMOVE = 0x8E, - CMSG_GUILD_DISBAND = 0x8F, - CMSG_GUILD_LEADER = 0x90, - CMSG_GUILD_MOTD = 0x91, - SMSG_GUILD_EVENT = 0x92, - SMSG_GUILD_COMMAND_RESULT = 0x93, - UMSG_UPDATE_GUILD = 0x94, - CMSG_MESSAGECHAT = 0x95, - SMSG_MESSAGECHAT = 0x96, - CMSG_JOIN_CHANNEL = 0x97, - CMSG_LEAVE_CHANNEL = 0x98, - SMSG_CHANNEL_NOTIFY = 0x99, - CMSG_CHANNEL_LIST = 0x9A, - SMSG_CHANNEL_LIST = 0x9B, - CMSG_CHANNEL_PASSWORD = 0x9C, - CMSG_CHANNEL_SET_OWNER = 0x9D, - CMSG_CHANNEL_OWNER = 0x9E, - CMSG_CHANNEL_MODERATOR = 0x9F, - CMSG_CHANNEL_UNMODERATOR = 0xA0, - CMSG_CHANNEL_MUTE = 0xA1, - CMSG_CHANNEL_UNMUTE = 0xA2, - CMSG_CHANNEL_INVITE = 0xA3, - CMSG_CHANNEL_KICK = 0xA4, - CMSG_CHANNEL_BAN = 0xA5, - CMSG_CHANNEL_UNBAN = 0xA6, - CMSG_CHANNEL_ANNOUNCEMENTS = 0xA7, - CMSG_CHANNEL_MODERATE = 0xA8, - SMSG_UPDATE_OBJECT = 0xA9, - SMSG_DESTROY_OBJECT = 0xAA, - CMSG_USE_ITEM = 0xAB, - CMSG_OPEN_ITEM = 0xAC, - CMSG_READ_ITEM = 0xAD, - SMSG_READ_ITEM_OK = 0xAE, - SMSG_READ_ITEM_FAILED = 0xAF, - SMSG_ITEM_COOLDOWN = 0xB0, - CMSG_GAMEOBJ_USE = 0xB1, - CMSG_GAMEOBJ_CHAIR_USE_OBSOLETE = 0xB2, - SMSG_GAMEOBJECT_CUSTOM_ANIM = 0xB3, - CMSG_AREATRIGGER = 0xB4, - MSG_MOVE_START_FORWARD = 0xB5, - MSG_MOVE_START_BACKWARD = 0xB6, - MSG_MOVE_STOP = 0xB7, - MSG_MOVE_START_STRAFE_LEFT = 0xB8, - MSG_MOVE_START_STRAFE_RIGHT = 0xB9, - MSG_MOVE_STOP_STRAFE = 0xBA, - MSG_MOVE_JUMP = 0xBB, - MSG_MOVE_START_TURN_LEFT = 0xBC, - MSG_MOVE_START_TURN_RIGHT = 0xBD, - MSG_MOVE_STOP_TURN = 0xBE, - MSG_MOVE_START_PITCH_UP = 0xBF, - MSG_MOVE_START_PITCH_DOWN = 0xC0, - MSG_MOVE_STOP_PITCH = 0xC1, - MSG_MOVE_SET_RUN_MODE = 0xC2, - MSG_MOVE_SET_WALK_MODE = 0xC3, - MSG_MOVE_TOGGLE_LOGGING = 0xC4, - MSG_MOVE_TELEPORT = 0xC5, - MSG_MOVE_TELEPORT_CHEAT = 0xC6, - MSG_MOVE_TELEPORT_ACK = 0xC7, - MSG_MOVE_TOGGLE_FALL_LOGGING = 0xC8, - MSG_MOVE_FALL_LAND = 0xC9, - MSG_MOVE_START_SWIM = 0xCA, - MSG_MOVE_STOP_SWIM = 0xCB, - MSG_MOVE_SET_RUN_SPEED_CHEAT = 0xCC, - MSG_MOVE_SET_RUN_SPEED = 0xCD, - MSG_MOVE_SET_RUN_BACK_SPEED_CHEAT = 0xCE, - MSG_MOVE_SET_RUN_BACK_SPEED = 0xCF, - MSG_MOVE_SET_WALK_SPEED_CHEAT = 0xD0, - MSG_MOVE_SET_WALK_SPEED = 0xD1, - MSG_MOVE_SET_SWIM_SPEED_CHEAT = 0xD2, - MSG_MOVE_SET_SWIM_SPEED = 0xD3, - MSG_MOVE_SET_SWIM_BACK_SPEED_CHEAT = 0xD4, - MSG_MOVE_SET_SWIM_BACK_SPEED = 0xD5, - MSG_MOVE_SET_ALL_SPEED_CHEAT = 0xD6, - MSG_MOVE_SET_TURN_RATE_CHEAT = 0xD7, - MSG_MOVE_SET_TURN_RATE = 0xD8, - MSG_MOVE_TOGGLE_COLLISION_CHEAT = 0xD9, - MSG_MOVE_SET_FACING = 0xDA, - MSG_MOVE_SET_PITCH = 0xDB, - MSG_MOVE_WORLDPORT_ACK = 0xDC, - SMSG_MONSTER_MOVE = 0xDD, - SMSG_MOVE_WATER_WALK = 0xDE, - SMSG_MOVE_LAND_WALK = 0xDF, - MSG_MOVE_SET_RAW_POSITION_ACK = 0xE0, - CMSG_MOVE_SET_RAW_POSITION = 0xE1, - SMSG_FORCE_RUN_SPEED_CHANGE = 0xE2, - CMSG_FORCE_RUN_SPEED_CHANGE_ACK = 0xE3, - SMSG_FORCE_RUN_BACK_SPEED_CHANGE = 0xE4, - CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK = 0xE5, - SMSG_FORCE_SWIM_SPEED_CHANGE = 0xE6, - CMSG_FORCE_SWIM_SPEED_CHANGE_ACK = 0xE7, - SMSG_FORCE_MOVE_ROOT = 0xE8, - CMSG_FORCE_MOVE_ROOT_ACK = 0xE9, - SMSG_FORCE_MOVE_UNROOT = 0xEA, - CMSG_FORCE_MOVE_UNROOT_ACK = 0xEB, - MSG_MOVE_ROOT = 0xEC, - MSG_MOVE_UNROOT = 0xED, - MSG_MOVE_HEARTBEAT = 0xEE, - SMSG_MOVE_KNOCK_BACK = 0xEF, - CMSG_MOVE_KNOCK_BACK_ACK = 0xF0, - MSG_MOVE_KNOCK_BACK = 0xF1, - SMSG_MOVE_FEATHER_FALL = 0xF2, - SMSG_MOVE_NORMAL_FALL = 0xF3, - SMSG_MOVE_SET_HOVER = 0xF4, - SMSG_MOVE_UNSET_HOVER = 0xF5, - CMSG_MOVE_HOVER_ACK = 0xF6, - MSG_MOVE_HOVER = 0xF7, - CMSG_TRIGGER_CINEMATIC_CHEAT = 0xF8, - CMSG_OPENING_CINEMATIC = 0xF9, - SMSG_TRIGGER_CINEMATIC = 0xFA, - CMSG_NEXT_CINEMATIC_CAMERA = 0xFB, - CMSG_COMPLETE_CINEMATIC = 0xFC, - SMSG_TUTORIAL_FLAGS = 0xFD, - CMSG_TUTORIAL_FLAG = 0xFE, - CMSG_TUTORIAL_CLEAR = 0xFF, - CMSG_TUTORIAL_RESET = 0x100, - CMSG_STANDSTATECHANGE = 0x101, - CMSG_EMOTE = 0x102, - SMSG_EMOTE = 0x103, - CMSG_TEXT_EMOTE = 0x104, - SMSG_TEXT_EMOTE = 0x105, - CMSG_AUTOEQUIP_GROUND_ITEM = 0x106, - CMSG_AUTOSTORE_GROUND_ITEM = 0x107, - CMSG_AUTOSTORE_LOOT_ITEM = 0x108, - CMSG_STORE_LOOT_IN_SLOT = 0x109, - CMSG_AUTOEQUIP_ITEM = 0x10A, - CMSG_AUTOSTORE_BAG_ITEM = 0x10B, - CMSG_SWAP_ITEM = 0x10C, - CMSG_SWAP_INV_ITEM = 0x10D, - CMSG_SPLIT_ITEM = 0x10E, - CMSG_PICKUP_ITEM = 0x10F, - CMSG_DROP_ITEM = 0x110, - CMSG_DESTROYITEM = 0x111, - SMSG_INVENTORY_CHANGE_FAILURE = 0x112, - SMSG_OPEN_CONTAINER = 0x113, - CMSG_INSPECT = 0x114, - SMSG_INSPECT = 0x115, - CMSG_INITIATE_TRADE = 0x116, - CMSG_BEGIN_TRADE = 0x117, - CMSG_BUSY_TRADE = 0x118, - CMSG_IGNORE_TRADE = 0x119, - CMSG_ACCEPT_TRADE = 0x11A, - CMSG_UNACCEPT_TRADE = 0x11B, - CMSG_CANCEL_TRADE = 0x11C, - CMSG_SET_TRADE_ITEM = 0x11D, - CMSG_CLEAR_TRADE_ITEM = 0x11E, - CMSG_SET_TRADE_GOLD = 0x11F, - SMSG_TRADE_STATUS = 0x120, - SMSG_TRADE_STATUS_EXTENDED = 0x121, - SMSG_INITIALIZE_FACTIONS = 0x122, - SMSG_SET_FACTION_VISIBLE = 0x123, - SMSG_SET_FACTION_STANDING = 0x124, - CMSG_SET_FACTION_ATWAR = 0x125, - CMSG_SET_FACTION_CHEAT = 0x126, - SMSG_SET_PROFICIENCY = 0x127, - CMSG_SET_ACTION_BUTTON = 0x128, - SMSG_ACTION_BUTTONS = 0x129, - SMSG_INITIAL_SPELLS = 0x12A, - SMSG_LEARNED_SPELL = 0x12B, - SMSG_SUPERCEDED_SPELL = 0x12C, - CMSG_NEW_SPELL_SLOT = 0x12D, - CMSG_CAST_SPELL = 0x12E, - CMSG_CANCEL_CAST = 0x12F, - SMSG_CAST_RESULT = 0x130, - SMSG_SPELL_START = 0x131, - SMSG_SPELL_GO = 0x132, - SMSG_SPELL_FAILURE = 0x133, - SMSG_SPELL_COOLDOWN = 0x134, - SMSG_COOLDOWN_EVENT = 0x135, - CMSG_CANCEL_AURA = 0x136, - SMSG_UPDATE_AURA_DURATION = 0x137, - SMSG_PET_CAST_FAILED = 0x138, - MSG_CHANNEL_START = 0x139, - MSG_CHANNEL_UPDATE = 0x13A, - CMSG_CANCEL_CHANNELLING = 0x13B, - SMSG_AI_REACTION = 0x13C, - CMSG_SET_SELECTION = 0x13D, - CMSG_SET_TARGET_OBSOLETE = 0x13E, - CMSG_UNUSED = 0x13F, - CMSG_UNUSED2 = 0x140, - CMSG_ATTACKSWING = 0x141, - CMSG_ATTACKSTOP = 0x142, - SMSG_ATTACKSTART = 0x143, - SMSG_ATTACKSTOP = 0x144, - SMSG_ATTACKSWING_NOTINRANGE = 0x145, - SMSG_ATTACKSWING_BADFACING = 0x146, - SMSG_ATTACKSWING_NOTSTANDING = 0x147, - SMSG_ATTACKSWING_DEADTARGET = 0x148, - SMSG_ATTACKSWING_CANT_ATTACK = 0x149, - SMSG_ATTACKERSTATEUPDATE = 0x14A, - SMSG_VICTIMSTATEUPDATE_OBSOLETE = 0x14B, - SMSG_DAMAGE_DONE_OBSOLETE = 0x14C, - SMSG_DAMAGE_TAKEN_OBSOLETE = 0x14D, - SMSG_CANCEL_COMBAT = 0x14E, - SMSG_PLAYER_COMBAT_XP_GAIN_OBSOLETE = 0x14F, - SMSG_HEALSPELL_ON_PLAYER_OBSOLETE = 0x150, - SMSG_HEALSPELL_ON_PLAYERS_PET_OBSOLETE = 0x151, - CMSG_SHEATHE_OBSOLETE = 0x152, - CMSG_SAVE_PLAYER = 0x153, - CMSG_SETDEATHBINDPOINT = 0x154, - SMSG_BINDPOINTUPDATE = 0x155, - CMSG_GETDEATHBINDZONE = 0x156, - SMSG_BINDZONEREPLY = 0x157, - SMSG_PLAYERBOUND = 0x158, - SMSG_DEATH_NOTIFY_OBSOLETE = 0x159, - CMSG_REPOP_REQUEST = 0x15A, - SMSG_RESURRECT_REQUEST = 0x15B, - CMSG_RESURRECT_RESPONSE = 0x15C, - CMSG_LOOT = 0x15D, - CMSG_LOOT_MONEY = 0x15E, - CMSG_LOOT_RELEASE = 0x15F, - SMSG_LOOT_RESPONSE = 0x160, - SMSG_LOOT_RELEASE_RESPONSE = 0x161, - SMSG_LOOT_REMOVED = 0x162, - SMSG_LOOT_MONEY_NOTIFY = 0x163, - SMSG_LOOT_ITEM_NOTIFY = 0x164, - SMSG_LOOT_CLEAR_MONEY = 0x165, - SMSG_ITEM_PUSH_RESULT = 0x166, - SMSG_DUEL_REQUESTED = 0x167, - SMSG_DUEL_OUTOFBOUNDS = 0x168, - SMSG_DUEL_INBOUNDS = 0x169, - SMSG_DUEL_COMPLETE = 0x16A, - SMSG_DUEL_WINNER = 0x16B, - CMSG_DUEL_ACCEPTED = 0x16C, - CMSG_DUEL_CANCELLED = 0x16D, - SMSG_MOUNTRESULT = 0x16E, - SMSG_DISMOUNTRESULT = 0x16F, - SMSG_PUREMOUNT_CANCELLED_OBSOLETE = 0x170, - CMSG_MOUNTSPECIAL_ANIM = 0x171, - SMSG_MOUNTSPECIAL_ANIM = 0x172, - SMSG_PET_TAME_FAILURE = 0x173, - CMSG_PET_SET_ACTION = 0x174, - CMSG_PET_ACTION = 0x175, - CMSG_PET_ABANDON = 0x176, - CMSG_PET_RENAME = 0x177, - SMSG_PET_NAME_INVALID = 0x178, - SMSG_PET_SPELLS = 0x179, - SMSG_PET_MODE = 0x17A, - CMSG_GOSSIP_HELLO = 0x17B, - CMSG_GOSSIP_SELECT_OPTION = 0x17C, - SMSG_GOSSIP_MESSAGE = 0x17D, - SMSG_GOSSIP_COMPLETE = 0x17E, - CMSG_NPC_TEXT_QUERY = 0x17F, - SMSG_NPC_TEXT_UPDATE = 0x180, - SMSG_NPC_WONT_TALK = 0x181, - CMSG_QUESTGIVER_STATUS_QUERY = 0x182, - SMSG_QUESTGIVER_STATUS = 0x183, - CMSG_QUESTGIVER_HELLO = 0x184, - SMSG_QUESTGIVER_QUEST_LIST = 0x185, - CMSG_QUESTGIVER_QUERY_QUEST = 0x186, - CMSG_QUESTGIVER_QUEST_AUTOLAUNCH = 0x187, - SMSG_QUESTGIVER_QUEST_DETAILS = 0x188, - CMSG_QUESTGIVER_ACCEPT_QUEST = 0x189, - CMSG_QUESTGIVER_COMPLETE_QUEST = 0x18A, - SMSG_QUESTGIVER_REQUEST_ITEMS = 0x18B, - CMSG_QUESTGIVER_REQUEST_REWARD = 0x18C, - SMSG_QUESTGIVER_OFFER_REWARD = 0x18D, - CMSG_QUESTGIVER_CHOOSE_REWARD = 0x18E, - SMSG_QUESTGIVER_QUEST_INVALID = 0x18F, - CMSG_QUESTGIVER_CANCEL = 0x190, - SMSG_QUESTGIVER_QUEST_COMPLETE = 0x191, - SMSG_QUESTGIVER_QUEST_FAILED = 0x192, - CMSG_QUESTLOG_SWAP_QUEST = 0x193, - CMSG_QUESTLOG_REMOVE_QUEST = 0x194, - SMSG_QUESTLOG_FULL = 0x195, - SMSG_QUESTUPDATE_FAILED = 0x196, - SMSG_QUESTUPDATE_FAILEDTIMER = 0x197, - SMSG_QUESTUPDATE_COMPLETE = 0x198, - SMSG_QUESTUPDATE_ADD_KILL = 0x199, - SMSG_QUESTUPDATE_ADD_ITEM = 0x19A, - CMSG_QUEST_CONFIRM_ACCEPT = 0x19B, - SMSG_QUEST_CONFIRM_ACCEPT = 0x19C, - CMSG_PUSHQUESTTOPARTY = 0x19D, - CMSG_LIST_INVENTORY = 0x19E, - SMSG_LIST_INVENTORY = 0x19F, - CMSG_SELL_ITEM = 0x1A0, - SMSG_SELL_ITEM = 0x1A1, - CMSG_BUY_ITEM = 0x1A2, - CMSG_BUY_ITEM_IN_SLOT = 0x1A3, - SMSG_BUY_ITEM = 0x1A4, - SMSG_BUY_FAILED = 0x1A5, - CMSG_TAXICLEARALLNODES = 0x1A6, - CMSG_TAXIENABLEALLNODES = 0x1A7, - CMSG_TAXISHOWNODES = 0x1A8, - SMSG_SHOWTAXINODES = 0x1A9, - CMSG_TAXINODE_STATUS_QUERY = 0x1AA, - SMSG_TAXINODE_STATUS = 0x1AB, - CMSG_TAXIQUERYAVAILABLENODES = 0x1AC, - CMSG_ACTIVATETAXI = 0x1AD, - SMSG_ACTIVATETAXIREPLY = 0x1AE, - SMSG_NEW_TAXI_PATH = 0x1AF, - CMSG_TRAINER_LIST = 0x1B0, - SMSG_TRAINER_LIST = 0x1B1, - CMSG_TRAINER_BUY_SPELL = 0x1B2, - SMSG_TRAINER_BUY_SUCCEEDED = 0x1B3, - SMSG_TRAINER_BUY_FAILED = 0x1B4, - CMSG_BINDER_ACTIVATE = 0x1B5, - SMSG_PLAYERBINDERROR = 0x1B6, - CMSG_BANKER_ACTIVATE = 0x1B7, - SMSG_SHOW_BANK = 0x1B8, - CMSG_BUY_BANK_SLOT = 0x1B9, - SMSG_BUY_BANK_SLOT_RESULT = 0x1BA, - CMSG_PETITION_SHOWLIST = 0x1BB, - SMSG_PETITION_SHOWLIST = 0x1BC, - CMSG_PETITION_BUY = 0x1BD, - CMSG_PETITION_SHOW_SIGNATURES = 0x1BE, - SMSG_PETITION_SHOW_SIGNATURES = 0x1BF, - CMSG_PETITION_SIGN = 0x1C0, - SMSG_PETITION_SIGN_RESULTS = 0x1C1, - MSG_PETITION_DECLINE = 0x1C2, - CMSG_OFFER_PETITION = 0x1C3, - CMSG_TURN_IN_PETITION = 0x1C4, - SMSG_TURN_IN_PETITION_RESULTS = 0x1C5, - CMSG_PETITION_QUERY = 0x1C6, - SMSG_PETITION_QUERY_RESPONSE = 0x1C7, - SMSG_FISH_NOT_HOOKED = 0x1C8, - SMSG_FISH_ESCAPED = 0x1C9, - CMSG_BUG = 0x1CA, - SMSG_NOTIFICATION = 0x1CB, - CMSG_PLAYED_TIME = 0x1CC, - SMSG_PLAYED_TIME = 0x1CD, - CMSG_QUERY_TIME = 0x1CE, - SMSG_QUERY_TIME_RESPONSE = 0x1CF, - SMSG_LOG_XPGAIN = 0x1D0, - MSG_SPLIT_MONEY = 0x1D1, - CMSG_RECLAIM_CORPSE = 0x1D2, - CMSG_WRAP_ITEM = 0x1D3, - SMSG_LEVELUP_INFO = 0x1D4, - MSG_MINIMAP_PING = 0x1D5, - SMSG_RESISTLOG = 0x1D6, - SMSG_ENCHANTMENTLOG = 0x1D7, - CMSG_SET_SKILL_CHEAT = 0x1D8, - SMSG_START_MIRROR_TIMER = 0x1D9, - SMSG_PAUSE_MIRROR_TIMER = 0x1DA, - SMSG_STOP_MIRROR_TIMER = 0x1DB, - CMSG_PING = 0x1DC, - SMSG_PONG = 0x1DD, - SMSG_CLEAR_COOLDOWN = 0x1DE, - SMSG_GAMEOBJECT_PAGETEXT = 0x1DF, - CMSG_SETSHEATHED = 0x1E0, - SMSG_COOLDOWN_CHEAT = 0x1E1, - SMSG_SPELL_DELAYED = 0x1E2, - CMSG_PLAYER_MACRO_OBSOLETE = 0x1E3, - SMSG_PLAYER_MACRO_OBSOLETE = 0x1E4, - CMSG_GHOST = 0x1E5, - CMSG_GM_INVIS = 0x1E6, - SMSG_INVALID_PROMOTION_CODE = 0x1E7, - MSG_GM_BIND_OTHER = 0x1E8, - MSG_GM_SUMMON = 0x1E9, - SMSG_ITEM_TIME_UPDATE = 0x1EA, - SMSG_ITEM_ENCHANT_TIME_UPDATE = 0x1EB, - SMSG_AUTH_CHALLENGE = 0x1EC, - CMSG_AUTH_SESSION = 0x1ED, - SMSG_AUTH_RESPONSE = 0x1EE, - MSG_GM_SHOWLABEL = 0x1EF, - MSG_ADD_DYNAMIC_TARGET_OBSOLETE = 0x1F0, - MSG_SAVE_GUILD_EMBLEM = 0x1F1, - MSG_TABARDVENDOR_ACTIVATE = 0x1F2, - SMSG_PLAY_SPELL_VISUAL = 0x1F3, - CMSG_ZONEUPDATE = 0x1F4, - SMSG_PARTYKILLLOG = 0x1F5, - SMSG_COMPRESSED_UPDATE_OBJECT = 0x1F6, - SMSG_OBSOLETE = 0x1F7, - SMSG_EXPLORATION_EXPERIENCE = 0x1F8, - CMSG_GM_SET_SECURITY_GROUP = 0x1F9, - CMSG_GM_NUKE = 0x1FA, - MSG_RANDOM_ROLL = 0x1FB, - SMSG_ENVIRONMENTALDAMAGELOG = 0x1FC, - CMSG_RWHOIS = 0x1FD, - SMSG_RWHOIS = 0x1FE, - MSG_LOOKING_FOR_GROUP = 0x1FF, - CMSG_SET_LOOKING_FOR_GROUP = 0x200, - CMSG_UNLEARN_SPELL = 0x201, - CMSG_UNLEARN_SKILL = 0x202, - SMSG_REMOVED_SPELL = 0x203, - CMSG_DECHARGE = 0x204, - CMSG_GMTICKET_CREATE = 0x205, - SMSG_GMTICKET_CREATE = 0x206, - CMSG_GMTICKET_UPDATETEXT = 0x207, - SMSG_GMTICKET_UPDATETEXT = 0x208, - SMSG_ACCOUNT_DATA_MD5 = 0x209, - CMSG_REQUEST_ACCOUNT_DATA = 0x20A, - CMSG_UPDATE_ACCOUNT_DATA = 0x20B, - SMSG_UPDATE_ACCOUNT_DATA = 0x20C, - SMSG_CLEAR_FAR_SIGHT_IMMEDIATE = 0x20D, - SMSG_POWERGAINLOG_OBSOLETE = 0x20E, - CMSG_GM_TEACH = 0x20F, - CMSG_GM_CREATE_ITEM_TARGET = 0x210, - CMSG_GMTICKET_GETTICKET = 0x211, - SMSG_GMTICKET_GETTICKET = 0x212, - CMSG_UNLEARN_TALENTS = 0x213, - SMSG_GAMEOBJECT_SPAWN_ANIM = 0x214, - SMSG_GAMEOBJECT_DESPAWN_ANIM = 0x215, - MSG_CORPSE_QUERY = 0x216, - CMSG_GMTICKET_DELETETICKET = 0x217, - SMSG_GMTICKET_DELETETICKET = 0x218, - SMSG_CHAT_WRONG_FACTION = 0x219, - CMSG_GMTICKET_SYSTEMSTATUS = 0x21A, - SMSG_GMTICKET_SYSTEMSTATUS = 0x21B, - CMSG_SPIRIT_HEALER_ACTIVATE = 0x21C, - CMSG_SET_STAT_CHEAT = 0x21D, - SMSG_SET_REST_START = 0x21E, - CMSG_SKILL_BUY_STEP = 0x21F, - CMSG_SKILL_BUY_RANK = 0x220, - CMSG_XP_CHEAT = 0x221, - SMSG_SPIRIT_HEALER_CONFIRM = 0x222, - CMSG_CHARACTER_POINT_CHEAT = 0x223, - SMSG_GOSSIP_POI = 0x224, - CMSG_CHAT_IGNORED = 0x225, - CMSG_GM_VISION = 0x226, - CMSG_SERVER_COMMAND = 0x227, - CMSG_GM_SILENCE = 0x228, - CMSG_GM_REVEALTO = 0x229, - CMSG_GM_RESURRECT = 0x22A, - CMSG_GM_SUMMONMOB = 0x22B, - CMSG_GM_MOVECORPSE = 0x22C, - CMSG_GM_FREEZE = 0x22D, - CMSG_GM_UBERINVIS = 0x22E, - CMSG_GM_REQUEST_PLAYER_INFO = 0x22F, - SMSG_GM_PLAYER_INFO = 0x230, - CMSG_GUILD_RANK = 0x231, - CMSG_GUILD_ADD_RANK = 0x232, - CMSG_GUILD_DEL_RANK = 0x233, - CMSG_GUILD_SET_PUBLIC_NOTE = 0x234, - CMSG_GUILD_SET_OFFICER_NOTE = 0x235, - SMSG_LOGIN_VERIFY_WORLD = 0x236, - CMSG_CLEAR_EXPLORATION = 0x237, - CMSG_SEND_MAIL = 0x238, - SMSG_SEND_MAIL_RESULT = 0x239, - CMSG_GET_MAIL_LIST = 0x23A, - SMSG_MAIL_LIST_RESULT = 0x23B, - CMSG_BATTLEFIELD_LIST = 0x23C, - SMSG_BATTLEFIELD_LIST = 0x23D, - CMSG_BATTLEFIELD_JOIN = 0x23E, - SMSG_BATTLEFIELD_WIN = 0x23F, - SMSG_BATTLEFIELD_LOSE = 0x240, - CMSG_TAXICLEARNODE = 0x241, - CMSG_TAXIENABLENODE = 0x242, - CMSG_ITEM_TEXT_QUERY = 0x243, - SMSG_ITEM_TEXT_QUERY_RESPONSE = 0x244, - CMSG_MAIL_TAKE_MONEY = 0x245, - CMSG_MAIL_TAKE_ITEM = 0x246, - CMSG_MAIL_MARK_AS_READ = 0x247, - CMSG_MAIL_RETURN_TO_SENDER = 0x248, - CMSG_MAIL_DELETE = 0x249, - CMSG_MAIL_CREATE_TEXT_ITEM = 0x24A, - SMSG_SPELLLOGMISS = 0x24B, - SMSG_SPELLLOGEXECUTE = 0x24C, - SMSG_DEBUGAURAPROC = 0x24D, - SMSG_PERIODICAURALOG = 0x24E, - SMSG_SPELLDAMAGESHIELD = 0x24F, - SMSG_SPELLNONMELEEDAMAGELOG = 0x250, - CMSG_LEARN_TALENT = 0x251, - SMSG_RESURRECT_FAILED = 0x252, - CMSG_TOGGLE_PVP = 0x253, - SMSG_ZONE_UNDER_ATTACK = 0x254, - MSG_AUCTION_HELLO = 0x255, - CMSG_AUCTION_SELL_ITEM = 0x256, - CMSG_AUCTION_REMOVE_ITEM = 0x257, - CMSG_AUCTION_LIST_ITEMS = 0x258, - CMSG_AUCTION_LIST_OWNER_ITEMS = 0x259, - CMSG_AUCTION_PLACE_BID = 0x25A, - SMSG_AUCTION_COMMAND_RESULT = 0x25B, - SMSG_AUCTION_LIST_RESULT = 0x25C, - SMSG_AUCTION_OWNER_LIST_RESULT = 0x25D, - SMSG_AUCTION_BIDDER_NOTIFICATION = 0x25E, - SMSG_AUCTION_OWNER_NOTIFICATION = 0x25F, - SMSG_PROCRESIST = 0x260, - SMSG_STANDSTATE_CHANGE_FAILURE = 0x261, - SMSG_DISPEL_FAILED = 0x262, - SMSG_SPELLORDAMAGE_IMMUNE = 0x263, - CMSG_AUCTION_LIST_BIDDER_ITEMS = 0x264, - SMSG_AUCTION_BIDDER_LIST_RESULT = 0x265, - SMSG_SET_FLAT_SPELL_MODIFIER = 0x266, - SMSG_SET_PCT_SPELL_MODIFIER = 0x267, - CMSG_SET_AMMO = 0x268, - SMSG_CORPSE_RECLAIM_DELAY = 0x269, - CMSG_SET_ACTIVE_MOVER = 0x26A, - CMSG_PET_CANCEL_AURA = 0x26B, - CMSG_PLAYER_AI_CHEAT = 0x26C, - CMSG_CANCEL_AUTO_REPEAT_SPELL = 0x26D, - MSG_GM_ACCOUNT_ONLINE = 0x26E, - MSG_LIST_STABLED_PETS = 0x26F, - CMSG_STABLE_PET = 0x270, - CMSG_UNSTABLE_PET = 0x271, - CMSG_BUY_STABLE_SLOT = 0x272, - SMSG_STABLE_RESULT = 0x273, - CMSG_STABLE_REVIVE_PET = 0x274, - CMSG_STABLE_SWAP_PET = 0x275, - MSG_QUEST_PUSH_RESULT = 0x276, - SMSG_PLAY_MUSIC = 0x277, - SMSG_PLAY_OBJECT_SOUND = 0x278, - CMSG_REQUEST_PET_INFO = 0x279, - CMSG_FAR_SIGHT = 0x27A, - SMSG_SPELLDISPELLOG = 0x27B, - SMSG_DAMAGE_CALC_LOG = 0x27C, - CMSG_ENABLE_DAMAGE_LOG = 0x27D, - CMSG_GROUP_CHANGE_SUB_GROUP = 0x27E, - CMSG_REQUEST_PARTY_MEMBER_STATS = 0x27F, - CMSG_GROUP_SWAP_SUB_GROUP = 0x280, - CMSG_RESET_FACTION_CHEAT = 0x281, - CMSG_AUTOSTORE_BANK_ITEM = 0x282, - CMSG_AUTOBANK_ITEM = 0x283, - MSG_QUERY_NEXT_MAIL_TIME = 0x284, - SMSG_RECEIVED_MAIL = 0x285, - SMSG_RAID_GROUP_ONLY = 0x286, - CMSG_SET_DURABILITY_CHEAT = 0x287, - CMSG_SET_PVP_RANK_CHEAT = 0x288, - CMSG_ADD_PVP_MEDAL_CHEAT = 0x289, - CMSG_DEL_PVP_MEDAL_CHEAT = 0x28A, - CMSG_SET_PVP_TITLE = 0x28B, - SMSG_PVP_CREDIT = 0x28C, - SMSG_AUCTION_REMOVED_NOTIFICATION = 0x28D, - CMSG_GROUP_RAID_CONVERT = 0x28E, - CMSG_GROUP_ASSISTANT = 0x28F, - CMSG_BUYBACK_ITEM = 0x290, - SMSG_SERVER_MESSAGE = 0x291, - CMSG_MEETINGSTONE_JOIN = 0x292, - CMSG_MEETINGSTONE_LEAVE = 0x293, - CMSG_MEETINGSTONE_CHEAT = 0x294, - SMSG_MEETINGSTONE_SETQUEUE = 0x295, - CMSG_MEETINGSTONE_INFO = 0x296, - SMSG_MEETINGSTONE_COMPLETE = 0x297, - SMSG_MEETINGSTONE_IN_PROGRESS = 0x298, - SMSG_MEETINGSTONE_MEMBER_ADDED = 0x299, - CMSG_GMTICKETSYSTEM_TOGGLE = 0x29A, - CMSG_CANCEL_GROWTH_AURA = 0x29B, - SMSG_CANCEL_AUTO_REPEAT = 0x29C, - SMSG_STANDSTATE_CHANGE_ACK = 0x29D, - SMSG_LOOT_ALL_PASSED = 0x29E, - SMSG_LOOT_ROLL_WON = 0x29F, - CMSG_LOOT_ROLL = 0x2A0, - SMSG_LOOT_START_ROLL = 0x2A1, - SMSG_LOOT_ROLL = 0x2A2, - CMSG_LOOT_MASTER_GIVE = 0x2A3, - SMSG_LOOT_MASTER_LIST = 0x2A4, - SMSG_SET_FORCED_REACTIONS = 0x2A5, - SMSG_SPELL_FAILED_OTHER = 0x2A6, - SMSG_GAMEOBJECT_RESET_STATE = 0x2A7, - CMSG_REPAIR_ITEM = 0x2A8, - SMSG_CHAT_PLAYER_NOT_FOUND = 0x2A9, - MSG_TALENT_WIPE_CONFIRM = 0x2AA, - SMSG_SUMMON_REQUEST = 0x2AB, - CMSG_SUMMON_RESPONSE = 0x2AC, - MSG_MOVE_TOGGLE_GRAVITY_CHEAT = 0x2AD, - SMSG_MONSTER_MOVE_TRANSPORT = 0x2AE, - SMSG_PET_BROKEN = 0x2AF, - MSG_MOVE_FEATHER_FALL = 0x2B0, - MSG_MOVE_WATER_WALK = 0x2B1, - CMSG_SERVER_BROADCAST = 0x2B2, - CMSG_SELF_RES = 0x2B3, - SMSG_FEIGN_DEATH_RESISTED = 0x2B4, - CMSG_RUN_SCRIPT = 0x2B5, - SMSG_SCRIPT_MESSAGE = 0x2B6, - SMSG_DUEL_COUNTDOWN = 0x2B7, - SMSG_AREA_TRIGGER_MESSAGE = 0x2B8, - CMSG_TOGGLE_HELM = 0x2B9, - CMSG_TOGGLE_CLOAK = 0x2BA, - SMSG_MEETINGSTONE_JOINFAILED = 0x2BB, - SMSG_PLAYER_SKINNED = 0x2BC, - SMSG_DURABILITY_DAMAGE_DEATH = 0x2BD, - CMSG_SET_EXPLORATION = 0x2BE, - CMSG_SET_ACTIONBAR_TOGGLES = 0x2BF, - UMSG_DELETE_GUILD_CHARTER = 0x2C0, - MSG_PETITION_RENAME = 0x2C1, - SMSG_INIT_WORLD_STATES = 0x2C2, - SMSG_UPDATE_WORLD_STATE = 0x2C3, - CMSG_ITEM_NAME_QUERY = 0x2C4, - SMSG_ITEM_NAME_QUERY_RESPONSE = 0x2C5, - SMSG_PET_ACTION_FEEDBACK = 0x2C6, - CMSG_CHAR_RENAME = 0x2C7, - SMSG_CHAR_RENAME = 0x2C8, - CMSG_MOVE_SPLINE_DONE = 0x2C9, - CMSG_MOVE_FALL_RESET = 0x2CA, - SMSG_INSTANCE_SAVE_CREATED = 0x2CB, - SMSG_RAID_INSTANCE_INFO = 0x2CC, - CMSG_REQUEST_RAID_INFO = 0x2CD, - CMSG_MOVE_TIME_SKIPPED = 0x2CE, - CMSG_MOVE_FEATHER_FALL_ACK = 0x2CF, - CMSG_MOVE_WATER_WALK_ACK = 0x2D0, - CMSG_MOVE_NOT_ACTIVE_MOVER = 0x2D1, - SMSG_PLAY_SOUND = 0x2D2, - CMSG_BATTLEFIELD_STATUS = 0x2D3, - SMSG_BATTLEFIELD_STATUS = 0x2D4, - CMSG_BATTLEFIELD_PORT = 0x2D5, - MSG_INSPECT_HONOR_STATS = 0x2D6, - CMSG_BATTLEMASTER_HELLO = 0x2D7, - CMSG_MOVE_START_SWIM_CHEAT = 0x2D8, - CMSG_MOVE_STOP_SWIM_CHEAT = 0x2D9, - SMSG_FORCE_WALK_SPEED_CHANGE = 0x2DA, - CMSG_FORCE_WALK_SPEED_CHANGE_ACK = 0x2DB, - SMSG_FORCE_SWIM_BACK_SPEED_CHANGE = 0x2DC, - CMSG_FORCE_SWIM_BACK_SPEED_CHANGE_ACK = 0x2DD, - SMSG_FORCE_TURN_RATE_CHANGE = 0x2DE, - CMSG_FORCE_TURN_RATE_CHANGE_ACK = 0x2DF, - MSG_PVP_LOG_DATA = 0x2E0, - CMSG_LEAVE_BATTLEFIELD = 0x2E1, - CMSG_AREA_SPIRIT_HEALER_QUERY = 0x2E2, - CMSG_AREA_SPIRIT_HEALER_QUEUE = 0x2E3, - SMSG_AREA_SPIRIT_HEALER_TIME = 0x2E4, - CMSG_GM_UNTEACH = 0x2E5, - SMSG_WARDEN_DATA = 0x2E6, - CMSG_WARDEN_DATA = 0x2E7, - SMSG_GROUP_JOINED_BATTLEGROUND = 0x2E8, - MSG_BATTLEGROUND_PLAYER_POSITIONS = 0x2E9, - CMSG_PET_STOP_ATTACK = 0x2EA, - SMSG_BINDER_CONFIRM = 0x2EB, - SMSG_BATTLEGROUND_PLAYER_JOINED = 0x2EC, - SMSG_BATTLEGROUND_PLAYER_LEFT = 0x2ED, - CMSG_BATTLEMASTER_JOIN = 0x2EE, - SMSG_ADDON_INFO = 0x2EF, - CMSG_PET_UNLEARN = 0x2F0, - SMSG_PET_UNLEARN_CONFIRM = 0x2F1, - SMSG_PARTY_MEMBER_STATS_FULL = 0x2F2, - CMSG_PET_SPELL_AUTOCAST = 0x2F3, - SMSG_WEATHER = 0x2F4, - SMSG_PLAY_TIME_WARNING = 0x2F5, - SMSG_MINIGAME_SETUP = 0x2F6, - SMSG_MINIGAME_STATE = 0x2F7, - CMSG_MINIGAME_MOVE = 0x2F8, - SMSG_MINIGAME_MOVE_FAILED = 0x2F9, - SMSG_RAID_INSTANCE_MESSAGE = 0x2FA, - SMSG_COMPRESSED_MOVES = 0x2FB, - CMSG_GUILD_INFO_TEXT = 0x2FC, - SMSG_CHAT_RESTRICTED = 0x2FD, - SMSG_SPLINE_SET_RUN_SPEED = 0x2FE, - SMSG_SPLINE_SET_RUN_BACK_SPEED = 0x2FF, - SMSG_SPLINE_SET_SWIM_SPEED = 0x300, - SMSG_SPLINE_SET_WALK_SPEED = 0x301, - SMSG_SPLINE_SET_SWIM_BACK_SPEED = 0x302, - SMSG_SPLINE_SET_TURN_RATE = 0x303, - SMSG_SPLINE_MOVE_UNROOT = 0x304, - SMSG_SPLINE_MOVE_FEATHER_FALL = 0x305, - SMSG_SPLINE_MOVE_NORMAL_FALL = 0x306, - SMSG_SPLINE_MOVE_SET_HOVER = 0x307, - SMSG_SPLINE_MOVE_UNSET_HOVER = 0x308, - SMSG_SPLINE_MOVE_WATER_WALK = 0x309, - SMSG_SPLINE_MOVE_LAND_WALK = 0x30A, - SMSG_SPLINE_MOVE_START_SWIM = 0x30B, - SMSG_SPLINE_MOVE_STOP_SWIM = 0x30C, - SMSG_SPLINE_MOVE_SET_RUN_MODE = 0x30D, - SMSG_SPLINE_MOVE_SET_WALK_MODE = 0x30E, - CMSG_GM_NUKE_ACCOUNT = 0x30F, - MSG_GM_DESTROY_CORPSE = 0x310, - CMSG_GM_DESTROY_ONLINE_CORPSE = 0x311, - CMSG_ACTIVATETAXI_FAR = 0x312, - SMSG_SET_FACTION_ATWAR = 0x313, - SMSG_GAMETIMEBIAS_SET = 0x314, - CMSG_DEBUG_ACTIONS_START = 0x315, - CMSG_DEBUG_ACTIONS_STOP = 0x316, - CMSG_SET_FACTION_INACTIVE = 0x317, - CMSG_SET_WATCHED_FACTION = 0x318, - MSG_MOVE_TIME_SKIPPED = 0x319, - SMSG_SPLINE_MOVE_ROOT = 0x31A, - CMSG_SET_EXPLORATION_ALL = 0x31B, - SMSG_INVALIDATE_PLAYER = 0x31C, - CMSG_RESET_INSTANCES = 0x31D, - SMSG_INSTANCE_RESET = 0x31E, - SMSG_INSTANCE_RESET_FAILED = 0x31F, - SMSG_UPDATE_LAST_INSTANCE = 0x320, - MSG_RAID_ICON_TARGET = 0x321, - MSG_RAID_READY_CHECK = 0x322, - CMSG_LUA_USAGE = 0x323, - SMSG_PET_ACTION_SOUND = 0x324, - SMSG_PET_DISMISS_SOUND = 0x325, - SMSG_GHOSTEE_GONE = 0x326, - CMSG_GM_UPDATE_TICKET_STATUS = 0x327, - SMSG_GM_TICKET_STATUS_UPDATE = 0x328, - CMSG_GMSURVEY_SUBMIT = 0x32A, - SMSG_UPDATE_INSTANCE_OWNERSHIP = 0x32B, - CMSG_IGNORE_KNOCKBACK_CHEAT = 0x32C, - SMSG_CHAT_PLAYER_AMBIGUOUS = 0x32D, - MSG_DELAY_GHOST_TELEPORT = 0x32E, - SMSG_SPELLINSTAKILLLOG = 0x32F, - SMSG_SPELL_UPDATE_CHAIN_TARGETS = 0x330, - CMSG_CHAT_FILTERED = 0x331, - SMSG_EXPECTED_SPAM_RECORDS = 0x332, - SMSG_SPELLSTEALLOG = 0x333, - CMSG_LOTTERY_QUERY_OBSOLETE = 0x334, - SMSG_LOTTERY_QUERY_RESULT_OBSOLETE = 0x335, - CMSG_BUY_LOTTERY_TICKET_OBSOLETE = 0x336, - SMSG_LOTTERY_RESULT_OBSOLETE = 0x337, - SMSG_CHARACTER_PROFILE = 0x338, - SMSG_CHARACTER_PROFILE_REALM_CONNECTED = 0x339, - SMSG_DEFENSE_MESSAGE = 0x33A, - MSG_GM_RESETINSTANCELIMIT = 0x33C, - - // // SMSG_MOTD = &H33D - SMSG_MOVE_SET_FLIGHT = 0x33E, - - SMSG_MOVE_UNSET_FLIGHT = 0x33F, - CMSG_MOVE_FLIGHT_ACK = 0x340, - MSG_MOVE_START_SWIM_CHEAT = 0x341, - MSG_MOVE_STOP_SWIM_CHEAT = 0x342, - SMSG_OUTDOORPVP_NOTIFY = 0x33B -} diff --git a/src/server/GameServer/Network/PacketReader.cs b/src/server/GameServer/Network/PacketReader.cs deleted file mode 100644 index af0f6931..00000000 --- a/src/server/GameServer/Network/PacketReader.cs +++ /dev/null @@ -1,113 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -using System.Buffers.Binary; -using System.Text; - -namespace GameServer.Network; - -internal sealed class PacketReader -{ - private Memory data; - - public int Remaining => data.Length; - - public PacketReader(Memory data) - { - this.data = data; - } - - public byte UInt8() - { - var value = data.Span[0]; - data = data.Slice(1); - return value; - } - - public ushort UInt16() - { - var value = BinaryPrimitives.ReadUInt16LittleEndian(data.Span); - data = data.Slice(sizeof(ushort)); - return value; - } - - public uint UInt32() - { - var value = BinaryPrimitives.ReadUInt32LittleEndian(data.Span); - data = data.Slice(sizeof(uint)); - return value; - } - - public ulong UInt64() - { - var value = BinaryPrimitives.ReadUInt64LittleEndian(data.Span); - data = data.Slice(sizeof(ulong)); - return value; - } - - public short Int16() - { - var value = BinaryPrimitives.ReadInt16LittleEndian(data.Span); - data = data.Slice(sizeof(short)); - return value; - } - - public int Int32() - { - var value = BinaryPrimitives.ReadInt32LittleEndian(data.Span); - data = data.Slice(sizeof(int)); - return value; - } - - public long Int64() - { - var value = BinaryPrimitives.ReadInt64LittleEndian(data.Span); - data = data.Slice(sizeof(long)); - return value; - } - - public float Float() - { - var value = BinaryPrimitives.ReadSingleLittleEndian(data.Span); - data = data.Slice(sizeof(float)); - return value; - } - - public string CString() - { - var span = data.Span; - var length = span.IndexOf((byte)0); - if (length < 0) length = span.Length; - - var value = Encoding.UTF8.GetString(span.Slice(0, length)); - data = data.Slice(Math.Min(length + 1, data.Length)); - return value; - } - - public ReadOnlyMemory Bytes(int count) - { - var value = data.Slice(0, count); - data = data.Slice(count); - return value; - } - - public void Skip(int count) - { - data = data.Slice(count); - } -} diff --git a/src/server/GameServer/Network/PacketWriter.cs b/src/server/GameServer/Network/PacketWriter.cs deleted file mode 100644 index 8cf6e72d..00000000 --- a/src/server/GameServer/Network/PacketWriter.cs +++ /dev/null @@ -1,107 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -using System.Buffers.Binary; -using System.Text; - -namespace GameServer.Network; - -internal sealed class PacketWriter -{ - private readonly Memory buffer; - private int offset = 4; - - public int Length => offset; - - public PacketWriter(Memory buffer, Opcodes opcode) - { - this.buffer = buffer; - var span = buffer.Slice(2).Span; - BinaryPrimitives.WriteUInt16LittleEndian(span, (ushort)opcode); - } - - public Memory ToPacket() - { - var span = buffer.Span; - BinaryPrimitives.WriteUInt16BigEndian(span, (ushort)(offset - 2)); - return buffer.Slice(0, offset); - } - - public void UInt8(byte value) - { - buffer.Span[offset] = value; - offset += 1; - } - - public void UInt16(ushort value) - { - BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(offset).Span, value); - offset += sizeof(ushort); - } - - public void UInt32(uint value) - { - BinaryPrimitives.WriteUInt32LittleEndian(buffer.Slice(offset).Span, value); - offset += sizeof(uint); - } - - public void UInt64(ulong value) - { - BinaryPrimitives.WriteUInt64LittleEndian(buffer.Slice(offset).Span, value); - offset += sizeof(ulong); - } - - public void Int16(short value) - { - BinaryPrimitives.WriteInt16LittleEndian(buffer.Slice(offset).Span, value); - offset += sizeof(short); - } - - public void Int32(int value) - { - BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(offset).Span, value); - offset += sizeof(int); - } - - public void Int64(long value) - { - BinaryPrimitives.WriteInt64LittleEndian(buffer.Slice(offset).Span, value); - offset += sizeof(long); - } - - public void Float(float value) - { - BinaryPrimitives.WriteSingleLittleEndian(buffer.Slice(offset).Span, value); - offset += sizeof(float); - } - - public void CString(string value) - { - var bytes = Encoding.UTF8.GetBytes(value); - bytes.CopyTo(buffer.Slice(offset)); - offset += bytes.Length; - buffer.Span[offset] = 0; - offset += 1; - } - - public void Bytes(ReadOnlySpan value) - { - value.CopyTo(buffer.Slice(offset).Span); - offset += value.Length; - } -} diff --git a/src/server/GameServer/Program.cs b/src/server/GameServer/Program.cs deleted file mode 100644 index 4f0cef2c..00000000 --- a/src/server/GameServer/Program.cs +++ /dev/null @@ -1,106 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -using Autofac; -using GameServer; -using Mangos.Cluster; -using Mangos.Common.Enums.Global; -using Mangos.Common.Globals; -using Mangos.Configuration; -using Mangos.Logging; -using Mangos.MySql; -using Mangos.MySql.Connections; -using Mangos.Tcp; -using Mangos.World; - -Console.Title = "Game server"; - -var builder = new ContainerBuilder(); -builder.RegisterModule(); -builder.RegisterModule(); -builder.RegisterModule(); -builder.RegisterModule(); -builder.RegisterModule(); -builder.RegisterModule(); -builder.RegisterModule(); - -var container = builder.Build(); -var configuration = container.Resolve(); -var logger = container.Resolve(); -var tcpServer = container.Resolve(); -var legacyWorldCluster = container.Resolve(); -WorldServiceLocator.Container = container; -var worldServer = container.Resolve(); - -logger.Trace(@" __ __ _ _ ___ ___ ___ "); -logger.Trace(@"| \/ |__ _| \| |/ __|/ _ \/ __| We Love "); -logger.Trace(@"| |\/| / _` | .` | (_ | (_) \__ \ Vanilla Wow"); -logger.Trace(@"|_| |_\__,_|_|\_|\___|\___/|___/ "); -logger.Trace(" "); -logger.Trace("Website / Forum / Support: https://www.getmangos.eu/"); - -// Check database version for account database -using (var scope = container.BeginLifetimeScope()) -{ - var accountConnection = scope.Resolve(); - var globalConstants = scope.Resolve(); - var dbVersionChecker = new DbVersionChecker(logger, globalConstants); - - if (!dbVersionChecker.CheckRequiredDbVersion(accountConnection.MySqlConnection, "account", ServerDb.Realm)) - { - logger.Error("Database version check failed. Exiting..."); - Environment.Exit(1); - } -} - -// Check database version for character database -using (var scope = container.BeginLifetimeScope()) -{ - var characterConnection = scope.Resolve(); - var globalConstants = scope.Resolve(); - var dbVersionChecker = new DbVersionChecker(logger, globalConstants); - - if (!dbVersionChecker.CheckRequiredDbVersion(characterConnection.MySqlConnection, "character", ServerDb.Character)) - { - logger.Error("Database version check failed. Exiting..."); - Environment.Exit(1); - } -} - -// Check database version for world database -using (var scope = container.BeginLifetimeScope()) -{ - var worldConnection = scope.Resolve(); - var globalConstants = scope.Resolve(); - var dbVersionChecker = new DbVersionChecker(logger, globalConstants); - - if (!dbVersionChecker.CheckRequiredDbVersion(worldConnection.MySqlConnection, "world", ServerDb.World)) - { - logger.Error("Database version check failed. Exiting..."); - Environment.Exit(1); - } -} - -logger.Information("Starting legacy cluster server"); -await legacyWorldCluster.StartAsync(); - -logger.Information("Starting legacy world server"); -await worldServer.StartAsync(); - -logger.Information("Starting game tcp server"); -await tcpServer.RunAsync(configuration.Cluster.ClusterServerEndpoint); diff --git a/src/server/GameServer/Requests/IRequestMessage.cs b/src/server/GameServer/Requests/IRequestMessage.cs deleted file mode 100644 index 87efb008..00000000 --- a/src/server/GameServer/Requests/IRequestMessage.cs +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -using GameServer.Network; - -namespace GameServer.Requests; - -internal interface IRequestMessage where T : IRequestMessage -{ - static abstract Opcodes Opcode - { - get; - } - - static abstract T Read(PacketReader reader); -} diff --git a/src/server/GameServer/Responses/IResponseMessage.cs b/src/server/GameServer/Responses/IResponseMessage.cs deleted file mode 100644 index c0a089a0..00000000 --- a/src/server/GameServer/Responses/IResponseMessage.cs +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -using GameServer.Network; - -namespace GameServer.Responses; - -internal interface IResponseMessage -{ - Opcodes Opcode - { - get; - } - - void Write(PacketWriter writer); -} diff --git a/src/server/GameServer/Services/GameState.cs b/src/server/GameServer/Services/GameState.cs deleted file mode 100644 index c78f5fa6..00000000 --- a/src/server/GameServer/Services/GameState.cs +++ /dev/null @@ -1,34 +0,0 @@ -// -// Copyright (C) 2013-2025 getMaNGOS -// -// This program is free software. You can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation. either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY. Without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, write to the Free Software -// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// - -using Mangos.Domain; - -namespace GameServer.Services; - -internal sealed class GameState : IGameState -{ - private readonly Game world = new(); - - public void Transaction(Action transaction) - { - lock (world) - { - transaction(world); - } - } -} diff --git a/src/server/Mangos.Cluster.Admin/Auth/PeerAuth.cs b/src/server/Mangos.Cluster.Admin/Auth/PeerAuth.cs new file mode 100644 index 00000000..7bec2284 --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Auth/PeerAuth.cs @@ -0,0 +1,60 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Mangos.Cluster.Admin.Auth; + +/// +/// HMAC-SHA256 helpers for peer cluster handshakes. The secret is the +/// shared bytes between two clusters; both sides hold a per-peer copy +/// in cluster.toml/configuration. Rotate the secret to evict. +/// +public static class PeerAuth +{ + public static byte[] ComputeHmac(byte[] secret, uint clusterId, byte[] nonce) + { + using var hmac = new HMACSHA256(secret); + var prefix = new byte[4]; + prefix[0] = (byte)(clusterId & 0xFF); + prefix[1] = (byte)((clusterId >> 8) & 0xFF); + prefix[2] = (byte)((clusterId >> 16) & 0xFF); + prefix[3] = (byte)((clusterId >> 24) & 0xFF); + hmac.TransformBlock(prefix, 0, prefix.Length, null, 0); + hmac.TransformFinalBlock(nonce, 0, nonce.Length); + return hmac.Hash ?? Array.Empty(); + } + + public static bool Verify(byte[] secret, uint clusterId, byte[] nonce, byte[] presented) + { + var expected = ComputeHmac(secret, clusterId, nonce); + return CryptographicOperations.FixedTimeEquals(expected, presented); + } + + public static byte[] FreshNonce(int sizeBytes = 16) + { + var n = new byte[sizeBytes]; + RandomNumberGenerator.Fill(n); + return n; + } + + /// Convenience: convert a UTF-8 string secret (config-friendly) into bytes. + public static byte[] SecretFromString(string s) => Encoding.UTF8.GetBytes(s ?? string.Empty); +} diff --git a/src/server/Mangos.Cluster.Admin/Commands/AdminCommand.cs b/src/server/Mangos.Cluster.Admin/Commands/AdminCommand.cs new file mode 100644 index 00000000..d10d6c5e --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Commands/AdminCommand.cs @@ -0,0 +1,150 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.Collections.Generic; +using System.IO; + +namespace Mangos.Cluster.Admin.Commands; + +/// +/// One admin invocation. Constructed by the in-game chat handler, the +/// cluster console REPL, the external CLI tool, or a peer cluster acting +/// on behalf of a remote operator. The dispatcher on the receiving side +/// applies the operation against local state. +/// +/// TargetRealmId == 0 means "this cluster"; non-zero routes the command +/// to the peer that owns that realmId in the realmlist. +/// +public sealed class AdminCommand +{ + /// Stable verb identifier; see . + public required AdminVerb Verb + { + get; init; + } + + /// 0 = local cluster; otherwise route to the peer that owns this realm. + public uint TargetRealmId + { + get; init; + } + + /// Optional: target a single world (e.g. .server shutdown --world W). + public string? WorldId + { + get; init; + } + + /// Optional: target a single instance (e.g. .instance restart --instance 1234). + public uint InstanceId + { + get; init; + } + + /// Optional: target a map (e.g. .instance spawn --map 530). + public uint MapId + { + get; init; + } + + /// Optional: graceful drain window before kill, in seconds. + public int GraceSeconds + { + get; init; + } + + /// Optional free-form arguments (key=value), preserved verbatim for the dispatcher. + public Dictionary Extras { get; init; } = new(); + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write((ushort)Verb); + bw.Write(TargetRealmId); + bw.Write(WorldId ?? string.Empty); + bw.Write(InstanceId); + bw.Write(MapId); + bw.Write(GraceSeconds); + bw.Write(Extras.Count); + foreach (var kv in Extras) + { + bw.Write(kv.Key ?? string.Empty); + bw.Write(kv.Value ?? string.Empty); + } + return ms.ToArray(); + } + + public static AdminCommand Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + var verb = (AdminVerb)br.ReadUInt16(); + var realm = br.ReadUInt32(); + var worldId = br.ReadString(); + var inst = br.ReadUInt32(); + var map = br.ReadUInt32(); + var grace = br.ReadInt32(); + var n = br.ReadInt32(); + var extras = new Dictionary(n); + for (int i = 0; i < n; i++) + { + var k = br.ReadString(); + var v = br.ReadString(); + extras[k] = v; + } + return new AdminCommand + { + Verb = verb, + TargetRealmId = realm, + WorldId = string.IsNullOrEmpty(worldId) ? null : worldId, + InstanceId = inst, + MapId = map, + GraceSeconds = grace, + Extras = extras, + }; + } +} + +/// Verb space for admin commands. See in-game ".server"/".instance"/".realm" handlers. +public enum AdminVerb : ushort +{ + Unknown = 0, + + // .server + ServerList = 0x0001, + ServerInfo = 0x0002, + ServerShutdown = 0x0003, + ServerRestart = 0x0004, + ServerStart = 0x0005, + ServerClaimMaps = 0x0006, + + // .instance + InstanceList = 0x0010, + InstanceInfo = 0x0011, + InstanceSpawn = 0x0012, + InstanceShutdown = 0x0013, + InstanceRestart = 0x0014, + InstanceKick = 0x0015, + + // .realm + RealmList = 0x0020, + RealmPeers = 0x0021, + RealmMarkerShow = 0x0022, + RealmMarkerHide = 0x0023, +} diff --git a/src/server/Mangos.Cluster.Admin/Commands/AdminCommandParser.cs b/src/server/Mangos.Cluster.Admin/Commands/AdminCommandParser.cs new file mode 100644 index 00000000..238ba440 --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Commands/AdminCommandParser.cs @@ -0,0 +1,142 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using System.Collections.Generic; + +namespace Mangos.Cluster.Admin.Commands; + +/// +/// Parser for the unified admin command syntax used by in-game chat, +/// the cluster console REPL, and the external CLI. +/// +/// Examples: +/// .server list +/// .server info --world W1 +/// .server shutdown --world W1 --grace 30 +/// .instance spawn --map 530 --realm 2 +/// .realm list +/// +/// Leading dot is optional. Tokens are space-separated; flag values are +/// the next token after a "--key" prefix. Unknown flags go into +/// . +/// +public static class AdminCommandParser +{ + public static bool TryParse(string input, out AdminCommand? command, out string? error) + { + command = null; + error = null; + if (string.IsNullOrWhiteSpace(input)) + { + error = "empty command"; + return false; + } + + var trimmed = input.TrimStart('.', ' '); + var tokens = Tokenize(trimmed); + if (tokens.Count < 2) + { + error = "expected at least "; + return false; + } + + var noun = tokens[0].ToLowerInvariant(); + var verb = tokens[1].ToLowerInvariant(); + var av = ResolveVerb(noun, verb); + if (av == AdminVerb.Unknown) + { + error = $"unknown command: {noun} {verb}"; + return false; + } + + var flags = new Dictionary(); + for (int i = 2; i < tokens.Count; i++) + { + var t = tokens[i]; + if (t.StartsWith("--", StringComparison.Ordinal)) + { + var k = t[2..]; + var v = i + 1 < tokens.Count ? tokens[++i] : "true"; + flags[k] = v; + } + else + { + flags[$"_pos{i}"] = t; + } + } + + command = new AdminCommand + { + Verb = av, + TargetRealmId = flags.TryGetValue("realm", out var r) && uint.TryParse(r, out var ri) ? ri : 0u, + WorldId = flags.TryGetValue("world", out var w) ? w : null, + InstanceId = flags.TryGetValue("instance", out var inst) && uint.TryParse(inst, out var instv) ? instv : 0u, + MapId = flags.TryGetValue("map", out var m) && uint.TryParse(m, out var mv) ? mv : 0u, + GraceSeconds = flags.TryGetValue("grace", out var g) && int.TryParse(g, out var gv) ? gv : 0, + Extras = flags, + }; + return true; + } + + private static AdminVerb ResolveVerb(string noun, string verb) => (noun, verb) switch + { + ("server", "list") => AdminVerb.ServerList, + ("server", "info") => AdminVerb.ServerInfo, + ("server", "shutdown") => AdminVerb.ServerShutdown, + ("server", "restart") => AdminVerb.ServerRestart, + ("server", "start") => AdminVerb.ServerStart, + ("server", "claim") => AdminVerb.ServerClaimMaps, + ("instance", "list") => AdminVerb.InstanceList, + ("instance", "info") => AdminVerb.InstanceInfo, + ("instance", "spawn") => AdminVerb.InstanceSpawn, + ("instance", "shutdown") => AdminVerb.InstanceShutdown, + ("instance", "restart") => AdminVerb.InstanceRestart, + ("instance", "kick") => AdminVerb.InstanceKick, + ("realm", "list") => AdminVerb.RealmList, + ("realm", "peers") => AdminVerb.RealmPeers, + ("realm", "marker") => AdminVerb.RealmMarkerShow, // disambiguated by Extras["_pos2"] + ("realm", "show") => AdminVerb.RealmMarkerShow, + ("realm", "hide") => AdminVerb.RealmMarkerHide, + _ => AdminVerb.Unknown, + }; + + private static List Tokenize(string s) + { + // Simple whitespace split with quoted-string support. + var result = new List(); + var cur = new System.Text.StringBuilder(); + bool inQuotes = false; + foreach (var c in s) + { + if (c == '"') + { + inQuotes = !inQuotes; + continue; + } + if (!inQuotes && char.IsWhiteSpace(c)) + { + if (cur.Length > 0) { result.Add(cur.ToString()); cur.Clear(); } + continue; + } + cur.Append(c); + } + if (cur.Length > 0) result.Add(cur.ToString()); + return result; + } +} diff --git a/src/server/Mangos.Cluster.Admin/Commands/AdminCommandReply.cs b/src/server/Mangos.Cluster.Admin/Commands/AdminCommandReply.cs new file mode 100644 index 00000000..8be0f119 --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Commands/AdminCommandReply.cs @@ -0,0 +1,69 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.Collections.Generic; +using System.IO; + +namespace Mangos.Cluster.Admin.Commands; + +/// +/// Response to an . Carries a status flag and +/// a list of human-readable text lines that the requester echoes to the +/// operator's chat / console / stdout. +/// +public sealed class AdminCommandReply +{ + public required AdminReplyStatus Status + { + get; init; + } + public List Lines { get; init; } = new(); + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write((byte)Status); + bw.Write(Lines.Count); + foreach (var l in Lines) + bw.Write(l ?? string.Empty); + return ms.ToArray(); + } + + public static AdminCommandReply Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + var status = (AdminReplyStatus)br.ReadByte(); + var n = br.ReadInt32(); + var lines = new List(n); + for (int i = 0; i < n; i++) + lines.Add(br.ReadString()); + return new AdminCommandReply { Status = status, Lines = lines }; + } +} + +public enum AdminReplyStatus : byte +{ + Ok = 0, + NotFound = 1, + NotPermitted = 2, + InvalidArguments = 3, + Unreachable = 4, + Failed = 5, +} diff --git a/src/server/Mangos.Cluster.Admin/Commands/ConsoleAdminRepl.cs b/src/server/Mangos.Cluster.Admin/Commands/ConsoleAdminRepl.cs new file mode 100644 index 00000000..c0e894cc --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Commands/ConsoleAdminRepl.cs @@ -0,0 +1,96 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Mangos.Cluster.Admin.Commands; + +/// +/// Tiny stdin-driven REPL for the cluster console. Reads lines, runs +/// them through , and dispatches to the +/// local . Same syntax used by the +/// in-game GM commands and the external CLI tool, so an operator can +/// exercise everything from any of three entrypoints. +/// +public sealed class ConsoleAdminRepl +{ + private readonly IAdminCommandHandler _handler; + private readonly Action _writeLine; + + public ConsoleAdminRepl(IAdminCommandHandler handler, Action? writeLine = null) + { + _handler = handler; + _writeLine = writeLine ?? Console.WriteLine; + } + + public Task RunAsync(CancellationToken ct = default) + { + return Task.Run(async () => + { + _writeLine("Cluster console ready. Type 'help' for commands."); + while (!ct.IsCancellationRequested) + { + string? line; + try { line = Console.ReadLine(); } + catch { return; } + if (line is null) return; // stdin closed + if (string.IsNullOrWhiteSpace(line)) continue; + + if (line.Trim().Equals("help", StringComparison.OrdinalIgnoreCase)) + { + PrintHelp(); + continue; + } + if (line.Trim().Equals("quit", StringComparison.OrdinalIgnoreCase) || + line.Trim().Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!AdminCommandParser.TryParse(line, out var cmd, out var err) || cmd is null) + { + _writeLine($"parse error: {err}"); + continue; + } + + var reply = await _handler.ExecuteAsync(cmd, ct); + _writeLine($"[{reply.Status}]"); + foreach (var l in reply.Lines) + _writeLine(l); + } + }, ct); + } + + private void PrintHelp() + { + _writeLine("Commands (leading '.' optional):"); + _writeLine(" .server list"); + _writeLine(" .server info --world "); + _writeLine(" .server shutdown --world [--grace ]"); + _writeLine(" .server restart --world [--grace ]"); + _writeLine(" .server start --world "); + _writeLine(" .instance list [--map ] [--realm ]"); + _writeLine(" .instance spawn --map [--realm ]"); + _writeLine(" .instance shutdown --instance [--realm ]"); + _writeLine(" .instance restart --instance [--realm ]"); + _writeLine(" .realm list"); + _writeLine("Add --realm to target a peer cluster."); + } +} diff --git a/src/server/GameServer/Network/HandlerDispatcher.cs b/src/server/Mangos.Cluster.Admin/Commands/IAdminCommandHandler.cs similarity index 56% rename from src/server/GameServer/Network/HandlerDispatcher.cs rename to src/server/Mangos.Cluster.Admin/Commands/IAdminCommandHandler.cs index fa103864..36d86281 100644 --- a/src/server/GameServer/Network/HandlerDispatcher.cs +++ b/src/server/Mangos.Cluster.Admin/Commands/IAdminCommandHandler.cs @@ -16,26 +16,20 @@ // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA // -using GameServer.Handlers; -using GameServer.Requests; +using System.Threading; +using System.Threading.Tasks; -namespace GameServer.Network; +namespace Mangos.Cluster.Admin.Commands; -internal sealed class HandlerDispatcher : IHandlerDispatcher - where TRequest : IRequestMessage - where THandler : IHandler +/// +/// Local executor for an that targets this +/// cluster (TargetRealmId == this cluster's realm id, or 0). +/// +/// The actual implementation lives in Mangos.Cluster (talks to the +/// supervisor); this interface is in the Admin project so the federation +/// transport can dispatch without referencing cluster internals. +/// +public interface IAdminCommandHandler { - private readonly THandler handler; - - public HandlerDispatcher(THandler handler) - { - this.handler = handler; - } - - public Opcodes Opcode => TRequest.Opcode; - - public Task ExectueAsync(PacketReader reader) - { - return handler.ExectueAsync(TRequest.Read(reader)); - } + Task ExecuteAsync(AdminCommand command, CancellationToken ct = default); } diff --git a/src/server/Mangos.Cluster.Admin/Mangos.Cluster.Admin.csproj b/src/server/Mangos.Cluster.Admin/Mangos.Cluster.Admin.csproj new file mode 100644 index 00000000..81b841d1 --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Mangos.Cluster.Admin.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + True + enable + enable + + + + + + + diff --git a/src/server/Mangos.Cluster.Admin/Protocol/AdminMethodId.cs b/src/server/Mangos.Cluster.Admin/Protocol/AdminMethodId.cs new file mode 100644 index 00000000..2be2e7a5 --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Protocol/AdminMethodId.cs @@ -0,0 +1,65 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +namespace Mangos.Cluster.Admin.Protocol; + +/// +/// Method ids carried in federation envelopes (cluster <-> cluster). +/// +/// Range 0x0300-0x03FF on the shared interop framing. Buckets: +/// +/// * 0x0300-0x030F: peer handshake + heartbeat. +/// * 0x0310-0x031F: admin RPC (server/instance/realm commands). +/// * 0x0320-0x033F: chat envelopes (PR #6). +/// * 0x0340-0x035F: group/raid envelopes (PR #6). +/// * 0x0360-0x036F: shard/co-location (Phase B). +/// * 0x0370-0x037F: presence/lookup. +/// +/// 0xFFFF stays reserved for the framing layer. +/// +public enum AdminMethodId : ushort +{ + // ----- Peer handshake ----------------------------------------------- + PeerHello = 0x0300, + PeerHelloAck = 0x0301, + PeerHeartbeat = 0x0302, + PeerGoodbye = 0x0303, + + // ----- Admin RPC ---------------------------------------------------- + AdminCommand = 0x0310, + AdminCommandReply = 0x0311, + + // ----- Chat (PR #6) ------------------------------------------------- + ChatRoute = 0x0320, + + // ----- Group / raid (PR #6) ----------------------------------------- + GroupInvite = 0x0340, + GroupInviteResponse = 0x0341, + GroupRosterUpdate = 0x0342, + GroupKick = 0x0343, + GroupDisband = 0x0344, + GroupMemberStatus = 0x0345, + + // ----- Shard / co-location (Phase B) -------------------------------- + ShardClaim = 0x0360, + ShardRelease = 0x0361, + + // ----- Presence / lookup -------------------------------------------- + PresenceQuery = 0x0370, + PresenceReply = 0x0371, +} diff --git a/src/server/Mangos.Cluster.Admin/Protocol/ChatEnvelope.cs b/src/server/Mangos.Cluster.Admin/Protocol/ChatEnvelope.cs new file mode 100644 index 00000000..d66d25d5 --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Protocol/ChatEnvelope.cs @@ -0,0 +1,140 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.IO; + +namespace Mangos.Cluster.Admin.Protocol; + +/// +/// Cross-realm chat envelope. Sent on the federation transport +/// (AdminMethodId.ChatRoute). The receiving cluster looks up the +/// destination by (Channel, RecipientName/RecipientGuid) and delivers +/// the message to that player; if they're online and federation +/// markers are enabled for them, the sender's display tag is rendered +/// in front of the sender name per the receiving realm's marker config. +/// +public sealed class ChatEnvelope +{ + /// Realm id the message originates from. + public required uint SenderRealmId + { + get; init; + } + + /// Display tag of the sender's realm (e.g. "WM"). + public required string SenderRealmTag + { + get; init; + } + + /// Sender's character GUID (home realm). + public required ulong SenderGuid + { + get; init; + } + + /// Sender's character name. + public required string SenderName + { + get; init; + } + + /// Channel: whisper, party, raid, guild, system, etc. + public required ChatChannel Channel + { + get; init; + } + + /// Whisper: target name (host realm of recipient lives in TargetRealmId of the AdminCommand wrapper). + public string? RecipientName + { + get; init; + } + + /// Whisper: target GUID if known. 0 means "look up by name". + public ulong RecipientGuid + { + get; init; + } + + /// Group/raid id when channel is Party/Raid. + public long GroupId + { + get; init; + } + + /// Wire language id (matches WoW's LANG_* constants). + public uint Language + { + get; init; + } + + /// The message body. + public required string Body + { + get; init; + } + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(SenderRealmId); + bw.Write(SenderRealmTag ?? string.Empty); + bw.Write(SenderGuid); + bw.Write(SenderName ?? string.Empty); + bw.Write((byte)Channel); + bw.Write(RecipientName ?? string.Empty); + bw.Write(RecipientGuid); + bw.Write(GroupId); + bw.Write(Language); + bw.Write(Body ?? string.Empty); + return ms.ToArray(); + } + + public static ChatEnvelope Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + return new ChatEnvelope + { + SenderRealmId = br.ReadUInt32(), + SenderRealmTag = br.ReadString(), + SenderGuid = br.ReadUInt64(), + SenderName = br.ReadString(), + Channel = (ChatChannel)br.ReadByte(), + RecipientName = br.ReadString() is { Length: > 0 } n ? n : null, + RecipientGuid = br.ReadUInt64(), + GroupId = br.ReadInt64(), + Language = br.ReadUInt32(), + Body = br.ReadString(), + }; + } +} + +public enum ChatChannel : byte +{ + Whisper = 0, + Party = 1, + Raid = 2, + Guild = 3, + GuildOfficer = 4, + System = 5, + /// Public custom channels (e.g. /join World). + NamedChannel = 6, +} diff --git a/src/server/Mangos.Cluster.Admin/Protocol/FederationLink.cs b/src/server/Mangos.Cluster.Admin/Protocol/FederationLink.cs new file mode 100644 index 00000000..7976aedb --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Protocol/FederationLink.cs @@ -0,0 +1,286 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Mangos.Cluster.Admin.Auth; +using Mangos.Cluster.Admin.Commands; +using Mangos.Cluster.Interop.Protocol; + +namespace Mangos.Cluster.Admin.Protocol; + +/// +/// Authenticated cluster <-> cluster connection. Wraps an +/// and reuses its framing; the only +/// difference from the world IPC is that the first frame must be a +/// signed PeerHello and the method-id range is 0x0300+. +/// +/// One instance per peer pair, opened by the dialer and accepted by +/// the listener. +/// +public sealed class FederationLink : IDisposable +{ + private readonly InteropConnection _connection; + + /// Remote cluster id, populated after a successful handshake. + public uint RemoteClusterId + { + get; private set; + } + + /// Remote display tag (e.g. "WM"), populated after a successful handshake. + public string RemoteDisplayTag { get; private set; } = string.Empty; + + /// True iff the handshake has completed. + public bool IsAuthenticated + { + get; private set; + } + + /// Bound by the cluster: handles inbound admin commands. + public IAdminCommandHandler? AdminHandler + { + get; set; + } + + /// + /// Bound by FederationServer on accept. Receives the incoming PeerHello, + /// returns either the accepted (clusterId, displayTag) or null on reject. + /// + public Func? OnPeerHello + { + get; set; + } + + /// Inbound cross-realm chat message (PR #6). + public Action? OnChatRoute + { + get; set; + } + + /// Inbound group invite from a peer cluster (PR #6). + public Action? OnGroupInvite + { + get; set; + } + + /// Inbound group invite response from a peer cluster (PR #6). + public Action? OnGroupInviteResponse + { + get; set; + } + + /// Inbound roster update for a federated group (PR #6). + public Action? OnGroupRosterUpdate + { + get; set; + } + + /// Inbound presence query - is the named character online here? (PR #6). + public Func? OnPresenceQuery + { + get; set; + } + + /// Inbound Phase B shard claim from a leader cluster (foreign member's home receives this). + public Action? OnShardClaim + { + get; set; + } + + /// Inbound Phase B shard release. + public Action? OnShardRelease + { + get; set; + } + + /// Fired when the underlying connection drops. + public event Action? Disconnected; + + public FederationLink(Socket socket) + { + _connection = new InteropConnection(socket); + _connection.OnMethodCallAsync = HandleAsync; + _connection.OnDisconnected = () => Disconnected?.Invoke(); + } + + /// Dialer side: send PeerHello, await PeerHelloAck. + public async Task ConnectAsAsync( + uint myClusterId, + string myDisplayTag, + byte[] peerSecret, + CancellationToken ct = default) + { + var nonce = PeerAuth.FreshNonce(); + var hello = new PeerHello + { + ClusterId = myClusterId, + Nonce = nonce, + Hmac = PeerAuth.ComputeHmac(peerSecret, myClusterId, nonce), + DisplayTag = myDisplayTag, + }; + + _connection.StartReceiving(); + var ackBytes = await _connection.SendRequestAsync( + (InteropMethodId)AdminMethodId.PeerHello, + hello.Serialize(), + timeoutMs: 10_000); + var ack = PeerHelloAck.Deserialize(ackBytes); + RemoteClusterId = ack.ClusterId; + RemoteDisplayTag = ack.DisplayTag; + IsAuthenticated = true; + } + + /// Send an admin command and await the reply. + public async Task SendAdminCommandAsync(AdminCommand cmd, int timeoutMs = 30_000) + { + if (!IsAuthenticated) + throw new InvalidOperationException("FederationLink is not authenticated"); + var bytes = await _connection.SendRequestAsync( + (InteropMethodId)AdminMethodId.AdminCommand, + cmd.Serialize(), + timeoutMs); + return AdminCommandReply.Deserialize(bytes); + } + + /// Forward a cross-realm chat envelope to this peer (fire-and-forget). + public Task SendChatAsync(ChatEnvelope env) + => _connection.SendOneWayAsync((InteropMethodId)AdminMethodId.ChatRoute, env.Serialize()); + + /// Forward a group invite to this peer. + public Task SendGroupInviteAsync(GroupInviteEnvelope env) + => _connection.SendOneWayAsync((InteropMethodId)AdminMethodId.GroupInvite, env.Serialize()); + + /// Forward an invite response to this peer. + public Task SendGroupInviteResponseAsync(GroupInviteResponseEnvelope env) + => _connection.SendOneWayAsync((InteropMethodId)AdminMethodId.GroupInviteResponse, env.Serialize()); + + /// Replicate a roster update to this peer. + public Task SendGroupRosterAsync(GroupRosterUpdateEnvelope env) + => _connection.SendOneWayAsync((InteropMethodId)AdminMethodId.GroupRosterUpdate, env.Serialize()); + + /// Ask the peer if it has the named character online. + public async Task QueryPresenceAsync(PresenceQueryEnvelope env, int timeoutMs = 5000) + { + var bytes = await _connection.SendRequestAsync( + (InteropMethodId)AdminMethodId.PresenceQuery, + env.Serialize(), + timeoutMs); + return PresenceReplyEnvelope.Deserialize(bytes); + } + + /// Liveness check; peer responds with an empty body. + public Task HeartbeatAsync(int timeoutMs = 5000) + => _connection.SendRequestAsync((InteropMethodId)AdminMethodId.PeerHeartbeat, Array.Empty(), timeoutMs); + + /// Phase B: claim a shard for a federated group. + public Task SendShardClaimAsync(ShardClaimEnvelope env) + => _connection.SendOneWayAsync((InteropMethodId)AdminMethodId.ShardClaim, env.Serialize()); + + /// Phase B: release a previously-claimed shard. + public Task SendShardReleaseAsync(ShardReleaseEnvelope env) + => _connection.SendOneWayAsync((InteropMethodId)AdminMethodId.ShardRelease, env.Serialize()); + + private async Task HandleAsync(InteropMethodId methodId, byte[] data) + { + var amid = (AdminMethodId)methodId; + switch (amid) + { + case AdminMethodId.PeerHello: + { + var hello = PeerHello.Deserialize(data); + var verdict = OnPeerHello?.Invoke(hello); + if (verdict is null) + { + // Auth rejected; reply with empty ack so the dialer fails fast. + return Array.Empty(); + } + var (remoteId, remoteTag) = verdict.Value; + RemoteClusterId = remoteId; + RemoteDisplayTag = remoteTag; + IsAuthenticated = true; + var ack = new PeerHelloAck { ClusterId = remoteId, DisplayTag = remoteTag }; + return ack.Serialize(); + } + + case AdminMethodId.AdminCommand: + { + if (!IsAuthenticated) + return new AdminCommandReply { Status = AdminReplyStatus.NotPermitted, Lines = { "not authenticated" } }.Serialize(); + var cmd = AdminCommand.Deserialize(data); + var handler = AdminHandler; + if (handler is null) + return new AdminCommandReply { Status = AdminReplyStatus.Failed, Lines = { "no admin handler bound" } }.Serialize(); + var reply = await handler.ExecuteAsync(cmd); + return reply.Serialize(); + } + + case AdminMethodId.PeerHeartbeat: + return Array.Empty(); + + case AdminMethodId.ChatRoute: + if (IsAuthenticated) + OnChatRoute?.Invoke(ChatEnvelope.Deserialize(data)); + return null; + + case AdminMethodId.GroupInvite: + if (IsAuthenticated) + OnGroupInvite?.Invoke(GroupInviteEnvelope.Deserialize(data)); + return null; + + case AdminMethodId.GroupInviteResponse: + if (IsAuthenticated) + OnGroupInviteResponse?.Invoke(GroupInviteResponseEnvelope.Deserialize(data)); + return null; + + case AdminMethodId.GroupRosterUpdate: + if (IsAuthenticated) + OnGroupRosterUpdate?.Invoke(GroupRosterUpdateEnvelope.Deserialize(data)); + return null; + + case AdminMethodId.PresenceQuery: + { + if (!IsAuthenticated || OnPresenceQuery is null) + return new PresenceReplyEnvelope { Name = "", Online = false }.Serialize(); + var q = PresenceQueryEnvelope.Deserialize(data); + var reply = OnPresenceQuery(q); + return reply.Serialize(); + } + + case AdminMethodId.ShardClaim: + if (IsAuthenticated) + OnShardClaim?.Invoke(ShardClaimEnvelope.Deserialize(data)); + return null; + + case AdminMethodId.ShardRelease: + if (IsAuthenticated) + OnShardRelease?.Invoke(ShardReleaseEnvelope.Deserialize(data)); + return null; + + default: + return null; + } + } + + /// The listener calls this after wiring its OnPeerHello handler. + internal void StartReceiving() => _connection.StartReceiving(); + + public void Dispose() => _connection.Dispose(); +} diff --git a/src/server/Mangos.Cluster.Admin/Protocol/FederationServer.cs b/src/server/Mangos.Cluster.Admin/Protocol/FederationServer.cs new file mode 100644 index 00000000..fced7e63 --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Protocol/FederationServer.cs @@ -0,0 +1,117 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Mangos.Cluster.Admin.Auth; +using Mangos.Cluster.Admin.Commands; + +namespace Mangos.Cluster.Admin.Protocol; + +/// +/// Listens for inbound peer cluster connections. Each accepted socket +/// receives the first PeerHello frame; the link's OnPeerHello callback +/// verifies the HMAC against the per-peer secret and either accepts +/// (returning the remote identity to send back as PeerHelloAck) or +/// rejects. +/// +public sealed class FederationServer : IDisposable +{ + private readonly Func _peerSecretLookup; + private readonly uint _localClusterId; + private readonly string _localDisplayTag; + private readonly ConcurrentDictionary _peers = new(); + private CancellationTokenSource? _cts; + private Socket? _listener; + + public FederationServer(uint localClusterId, string localDisplayTag, Func peerSecretLookup) + { + _localClusterId = localClusterId; + _localDisplayTag = localDisplayTag; + _peerSecretLookup = peerSecretLookup; + } + + public IReadOnlyDictionary Peers => _peers; + + /// Bound by the cluster: invoked for inbound admin commands on every accepted link. + public IAdminCommandHandler? AdminHandler + { + get; set; + } + + /// Optional hook fired per accepted link, used to wire chat/group/presence handlers. + public Action? OnLinkAccepted + { + get; set; + } + + public Task StartAsync(string bindAddress, int port) + { + _cts = new CancellationTokenSource(); + _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + _listener.Bind(new IPEndPoint(IPAddress.Parse(bindAddress), port)); + _listener.Listen(64); + _ = Task.Run(() => AcceptLoopAsync(_cts.Token)); + return Task.CompletedTask; + } + + private async Task AcceptLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && _listener is not null) + { + Socket socket; + try + { + socket = await _listener.AcceptAsync(ct); + } + catch (OperationCanceledException) { return; } + catch { continue; } + + socket.NoDelay = true; + var link = new FederationLink(socket) + { + AdminHandler = AdminHandler, + }; + OnLinkAccepted?.Invoke(link); + link.OnPeerHello = hello => + { + var secret = _peerSecretLookup(hello.ClusterId); + if (secret is null) return null; + if (!PeerAuth.Verify(secret, hello.ClusterId, hello.Nonce, hello.Hmac)) + return null; + _peers[hello.ClusterId] = link; + link.Disconnected += () => _peers.TryRemove(hello.ClusterId, out _); + return (_localClusterId, _localDisplayTag); + }; + link.StartReceiving(); + } + } + + public void Dispose() + { + _cts?.Cancel(); + try { _listener?.Close(); } catch { } + foreach (var l in _peers.Values) try { l.Dispose(); } catch { } + _peers.Clear(); + } +} diff --git a/src/server/Mangos.Cluster.Admin/Protocol/GroupEnvelope.cs b/src/server/Mangos.Cluster.Admin/Protocol/GroupEnvelope.cs new file mode 100644 index 00000000..0ef54d22 --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Protocol/GroupEnvelope.cs @@ -0,0 +1,244 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.Collections.Generic; +using System.IO; + +namespace Mangos.Cluster.Admin.Protocol; + +/// +/// Cross-realm group invite envelope (AdminMethodId.GroupInvite). +/// The leader's cluster sends this to the recipient's cluster; the +/// recipient's cluster surfaces a standard group-invite popup to the +/// targeted character if they're online. +/// +public sealed class GroupInviteEnvelope +{ + public required long GroupId + { + get; init; + } + public required uint LeaderRealmId + { + get; init; + } + public required ulong LeaderGuid + { + get; init; + } + public required string LeaderName + { + get; init; + } + public required string LeaderRealmTag + { + get; init; + } + public required string TargetName + { + get; init; + } + public byte GroupType + { + get; init; + } // 0 party, 1 raid + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(GroupId); + bw.Write(LeaderRealmId); + bw.Write(LeaderGuid); + bw.Write(LeaderName ?? string.Empty); + bw.Write(LeaderRealmTag ?? string.Empty); + bw.Write(TargetName ?? string.Empty); + bw.Write(GroupType); + return ms.ToArray(); + } + + public static GroupInviteEnvelope Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + return new GroupInviteEnvelope + { + GroupId = br.ReadInt64(), + LeaderRealmId = br.ReadUInt32(), + LeaderGuid = br.ReadUInt64(), + LeaderName = br.ReadString(), + LeaderRealmTag = br.ReadString(), + TargetName = br.ReadString(), + GroupType = br.ReadByte(), + }; + } +} + +/// +/// Reply to a GroupInviteEnvelope. Travels along the same federation +/// link in the opposite direction. +/// +public sealed class GroupInviteResponseEnvelope +{ + public required long GroupId + { + get; init; + } + public required uint TargetRealmId + { + get; init; + } + public required ulong TargetGuid + { + get; init; + } + public required string TargetName + { + get; init; + } + public required GroupInviteResponse Decision + { + get; init; + } + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(GroupId); + bw.Write(TargetRealmId); + bw.Write(TargetGuid); + bw.Write(TargetName ?? string.Empty); + bw.Write((byte)Decision); + return ms.ToArray(); + } + + public static GroupInviteResponseEnvelope Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + return new GroupInviteResponseEnvelope + { + GroupId = br.ReadInt64(), + TargetRealmId = br.ReadUInt32(), + TargetGuid = br.ReadUInt64(), + TargetName = br.ReadString(), + Decision = (GroupInviteResponse)br.ReadByte(), + }; + } +} + +public enum GroupInviteResponse : byte +{ + Accepted = 0, + Declined = 1, + AlreadyInGroup = 2, + NotFound = 3, + Timeout = 4, +} + +/// +/// Authoritative roster snapshot for a federated group. Sent from the +/// leader's cluster to every peer that owns at least one member, on +/// every roster change. Replicas overwrite their local copy. +/// +public sealed class GroupRosterUpdateEnvelope +{ + public required long GroupId + { + get; init; + } + public required uint LeaderRealmId + { + get; init; + } + public required ulong LeaderGuid + { + get; init; + } + public byte GroupType + { + get; init; + } + public List Members { get; init; } = new(); + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(GroupId); + bw.Write(LeaderRealmId); + bw.Write(LeaderGuid); + bw.Write(GroupType); + bw.Write(Members.Count); + foreach (var m in Members) + { + bw.Write(m.RealmId); + bw.Write(m.Guid); + bw.Write(m.Name ?? string.Empty); + bw.Write(m.Role); + } + return ms.ToArray(); + } + + public static GroupRosterUpdateEnvelope Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + var env = new GroupRosterUpdateEnvelope + { + GroupId = br.ReadInt64(), + LeaderRealmId = br.ReadUInt32(), + LeaderGuid = br.ReadUInt64(), + GroupType = br.ReadByte(), + }; + var n = br.ReadInt32(); + for (int i = 0; i < n; i++) + { + env.Members.Add(new GroupMemberEntry + { + RealmId = br.ReadUInt32(), + Guid = br.ReadUInt64(), + Name = br.ReadString(), + Role = br.ReadByte(), + }); + } + return env; + } +} + +public sealed class GroupMemberEntry +{ + public required uint RealmId + { + get; init; + } + public required ulong Guid + { + get; init; + } + public required string Name + { + get; init; + } + /// Bitfield: 1=leader, 2=assist, 4=mainTank, 8=mainAssist. + public byte Role + { + get; init; + } +} diff --git a/src/server/Mangos.Cluster.Admin/Protocol/PeerHello.cs b/src/server/Mangos.Cluster.Admin/Protocol/PeerHello.cs new file mode 100644 index 00000000..4fd2064c --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Protocol/PeerHello.cs @@ -0,0 +1,116 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.IO; + +namespace Mangos.Cluster.Admin.Protocol; + +/// +/// First frame on a federation connection. Carries the dialer's cluster id, +/// a fresh nonce, and an HMAC over (clusterId || nonce) using a shared +/// secret configured per peer. The receiver verifies the HMAC against its +/// peer table; on success it replies with a PeerHelloAck and the link is +/// authenticated for the lifetime of the connection. +/// +/// We don't roll our own crypto: the HMAC uses HMAC-SHA256 from BCL. +/// Anyone with the peer secret can join; rotate the secret to evict. +/// +public sealed class PeerHello +{ + public required uint ClusterId + { + get; init; + } + public required byte[] Nonce + { + get; init; + } + public required byte[] Hmac + { + get; init; + } + public required string DisplayTag + { + get; init; + } + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(ClusterId); + bw.Write(Nonce.Length); + bw.Write(Nonce); + bw.Write(Hmac.Length); + bw.Write(Hmac); + bw.Write(DisplayTag ?? string.Empty); + return ms.ToArray(); + } + + public static PeerHello Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + var clusterId = br.ReadUInt32(); + var nonceLen = br.ReadInt32(); + var nonce = br.ReadBytes(nonceLen); + var hmacLen = br.ReadInt32(); + var hmac = br.ReadBytes(hmacLen); + var tag = br.ReadString(); + return new PeerHello + { + ClusterId = clusterId, + Nonce = nonce, + Hmac = hmac, + DisplayTag = tag, + }; + } +} + +/// Acknowledgement frame for a successful peer handshake. +public sealed class PeerHelloAck +{ + public required uint ClusterId + { + get; init; + } + public required string DisplayTag + { + get; init; + } + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(ClusterId); + bw.Write(DisplayTag ?? string.Empty); + return ms.ToArray(); + } + + public static PeerHelloAck Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + return new PeerHelloAck + { + ClusterId = br.ReadUInt32(), + DisplayTag = br.ReadString(), + }; + } +} diff --git a/src/server/Mangos.Cluster.Admin/Protocol/PresenceEnvelope.cs b/src/server/Mangos.Cluster.Admin/Protocol/PresenceEnvelope.cs new file mode 100644 index 00000000..6de5f3cb --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Protocol/PresenceEnvelope.cs @@ -0,0 +1,99 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.IO; + +namespace Mangos.Cluster.Admin.Protocol; + +/// +/// "Is this character online on your realm?" query, used during +/// cross-realm whispers and group invites when the sender only knows +/// the recipient's name. +/// +public sealed class PresenceQueryEnvelope +{ + public required string Name + { + get; init; + } + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(Name ?? string.Empty); + return ms.ToArray(); + } + + public static PresenceQueryEnvelope Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + return new PresenceQueryEnvelope { Name = br.ReadString() }; + } +} + +public sealed class PresenceReplyEnvelope +{ + public required string Name + { + get; init; + } + public required bool Online + { + get; init; + } + public ulong Guid + { + get; init; + } + public uint MapId + { + get; init; + } + public uint ZoneId + { + get; init; + } + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(Name ?? string.Empty); + bw.Write(Online); + bw.Write(Guid); + bw.Write(MapId); + bw.Write(ZoneId); + return ms.ToArray(); + } + + public static PresenceReplyEnvelope Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + return new PresenceReplyEnvelope + { + Name = br.ReadString(), + Online = br.ReadBoolean(), + Guid = br.ReadUInt64(), + MapId = br.ReadUInt32(), + ZoneId = br.ReadUInt32(), + }; + } +} diff --git a/src/server/Mangos.Cluster.Admin/Protocol/ShardEnvelope.cs b/src/server/Mangos.Cluster.Admin/Protocol/ShardEnvelope.cs new file mode 100644 index 00000000..441b6536 --- /dev/null +++ b/src/server/Mangos.Cluster.Admin/Protocol/ShardEnvelope.cs @@ -0,0 +1,120 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.IO; + +namespace Mangos.Cluster.Admin.Protocol; + +/// +/// Phase B shard claim. Sent by a leader's cluster to a peer when a +/// federated group enters a shardable zone, so the peer routes its +/// member's world packets through the leader's cluster's world for the +/// duration. Receipt is best-effort; the peer may decline if it cannot +/// reach the host shard endpoint. +/// +public sealed class ShardClaimEnvelope +{ + /// Owning cluster's group id (matches federation_group.groupId). + public required long GroupId + { + get; init; + } + + /// Cluster id that owns the shard (= the host). + public required uint OwnerClusterId + { + get; init; + } + + /// WoW map id this shard covers. + public required uint MapId + { + get; init; + } + + /// Stable shard key; clients with the same key end up co-located. + public required ulong ShardKey + { + get; init; + } + + /// Where to forward foreign-member world packets (host:port of host cluster's relay). + public required string RelayEndpoint + { + get; init; + } + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(GroupId); + bw.Write(OwnerClusterId); + bw.Write(MapId); + bw.Write(ShardKey); + bw.Write(RelayEndpoint ?? string.Empty); + return ms.ToArray(); + } + + public static ShardClaimEnvelope Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + return new ShardClaimEnvelope + { + GroupId = br.ReadInt64(), + OwnerClusterId = br.ReadUInt32(), + MapId = br.ReadUInt32(), + ShardKey = br.ReadUInt64(), + RelayEndpoint = br.ReadString(), + }; + } +} + +/// Counterpart to ShardClaimEnvelope; releases the shard when the group disbands or the leader logs off. +public sealed class ShardReleaseEnvelope +{ + public required long GroupId + { + get; init; + } + public required ulong ShardKey + { + get; init; + } + + public byte[] Serialize() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(GroupId); + bw.Write(ShardKey); + return ms.ToArray(); + } + + public static ShardReleaseEnvelope Deserialize(byte[] data) + { + using var ms = new MemoryStream(data); + using var br = new BinaryReader(ms); + return new ShardReleaseEnvelope + { + GroupId = br.ReadInt64(), + ShardKey = br.ReadUInt64(), + }; + } +} diff --git a/src/server/Mangos.Cluster.Interop/Dispatchers/ClusterInteropDispatcher.cs b/src/server/Mangos.Cluster.Interop/Dispatchers/ClusterInteropDispatcher.cs index f6f57417..66e49081 100644 --- a/src/server/Mangos.Cluster.Interop/Dispatchers/ClusterInteropDispatcher.cs +++ b/src/server/Mangos.Cluster.Interop/Dispatchers/ClusterInteropDispatcher.cs @@ -22,11 +22,15 @@ namespace Mangos.Cluster.Interop.Dispatchers; /// -/// Dispatches incoming ICluster method calls from a world server TCP connection -/// to the real ICluster implementation on the cluster side. +/// Cluster-side dispatcher: turns inbound envelopes from a world server into +/// ICluster method calls. The IWorld counterpart is a WorldInteropProxy +/// bound to the same connection - exposed here so the cluster can route +/// outbound (cluster -> world) traffic over it. /// -/// Also handles the special Connect call: creates a WorldInteropProxy from the -/// same TCP connection and passes it as the IWorld parameter. +/// Envelope organization mirrors : +/// 1. Relay: PacketOut (world -> cluster). +/// 2. Directives: drop/transfer/update/chat-flag/broadcast/group-update. +/// 3. Control RPC: world hello/goodbye, crypt-key read, battlefield list. /// public sealed class ClusterInteropDispatcher { @@ -41,9 +45,7 @@ public ClusterInteropDispatcher(ICluster cluster, InteropConnection connection) _worldProxy = new WorldInteropProxy(connection); } - /// - /// Gets the IWorld proxy for the remote world server on this connection. - /// + /// The IWorld proxy for the world server on this connection. public IWorld WorldProxy => _worldProxy; public byte[]? Dispatch(InteropMethodId methodId, byte[] data) @@ -53,39 +55,24 @@ public ClusterInteropDispatcher(ICluster cluster, InteropConnection connection) switch (methodId) { - case InteropMethodId.ClusterConnect: - { - var uri = br.ReadString(); - var maps = InteropSerializer.ReadUInt32List(br); - // Pass the WorldInteropProxy as the IWorld parameter - var result = _cluster.Connect(uri, maps, _worldProxy); - return new[] { result ? (byte)1 : (byte)0 }; - } - - case InteropMethodId.ClusterDisconnect: - { - var uri = br.ReadString(); - var maps = InteropSerializer.ReadUInt32List(br); - _cluster.Disconnect(uri, maps); - return null; - } - - case InteropMethodId.ClusterClientSend: + // ---- 1. Relay ----------------------------------------------- + case InteropMethodId.PacketOut: { var id = br.ReadUInt32(); - var packetData = InteropSerializer.ReadByteArray(br); - _cluster.ClientSend(id, packetData); + var packet = InteropSerializer.ReadByteArray(br); + _cluster.ClientSend(id, packet); return null; } - case InteropMethodId.ClusterClientDrop: + // ---- 2. Directives ------------------------------------------ + case InteropMethodId.DirectiveDropClient: { var id = br.ReadUInt32(); _cluster.ClientDrop(id); return null; } - case InteropMethodId.ClusterClientTransfer: + case InteropMethodId.DirectiveTransferClient: { var id = br.ReadUInt32(); var posX = br.ReadSingle(); @@ -97,7 +84,7 @@ public ClusterInteropDispatcher(ICluster cluster, InteropConnection connection) return null; } - case InteropMethodId.ClusterClientUpdate: + case InteropMethodId.DirectiveUpdateClient: { var id = br.ReadUInt32(); var zone = br.ReadUInt32(); @@ -106,7 +93,7 @@ public ClusterInteropDispatcher(ICluster cluster, InteropConnection connection) return null; } - case InteropMethodId.ClusterClientSetChatFlag: + case InteropMethodId.DirectiveSetClientChatFlag: { var id = br.ReadUInt32(); var flag = br.ReadByte(); @@ -114,73 +101,121 @@ public ClusterInteropDispatcher(ICluster cluster, InteropConnection connection) return null; } - case InteropMethodId.ClusterClientGetCryptKey: + case InteropMethodId.DirectiveBroadcast: { - var id = br.ReadUInt32(); - var key = _cluster.ClientGetCryptKey(id); - return InteropSerializer.WriteByteArray(key); + var packet = InteropSerializer.ReadByteArray(br); + _cluster.Broadcast(packet); + return null; } - case InteropMethodId.ClusterBattlefieldList: + case InteropMethodId.DirectiveBroadcastGroup: { - var type = br.ReadByte(); - var list = _cluster.BattlefieldList(type); - return InteropSerializer.WriteInt32List(list); + var groupId = br.ReadInt64(); + var packet = InteropSerializer.ReadByteArray(br); + _cluster.BroadcastGroup(groupId, packet); + return null; } - case InteropMethodId.ClusterBattlefieldFinish: + case InteropMethodId.DirectiveBroadcastRaid: { - var battlefieldId = br.ReadInt32(); - _cluster.BattlefieldFinish(battlefieldId); + var groupId = br.ReadInt64(); + var packet = InteropSerializer.ReadByteArray(br); + _cluster.BroadcastRaid(groupId, packet); return null; } - case InteropMethodId.ClusterBroadcast: + case InteropMethodId.DirectiveBroadcastGuild: { - var packetData = InteropSerializer.ReadByteArray(br); - _cluster.Broadcast(packetData); + var guildId = br.ReadInt64(); + var packet = InteropSerializer.ReadByteArray(br); + _cluster.BroadcastGuild(guildId, packet); return null; } - case InteropMethodId.ClusterBroadcastGroup: + case InteropMethodId.DirectiveBroadcastGuildOfficers: { - var groupId = br.ReadInt64(); - var packetData = InteropSerializer.ReadByteArray(br); - _cluster.BroadcastGroup(groupId, packetData); + var guildId = br.ReadInt64(); + var packet = InteropSerializer.ReadByteArray(br); + _cluster.BroadcastGuildOfficers(guildId, packet); return null; } - case InteropMethodId.ClusterBroadcastRaid: + case InteropMethodId.DirectiveGroupRequestUpdate: { - var groupId = br.ReadInt64(); - var packetData = InteropSerializer.ReadByteArray(br); - _cluster.BroadcastRaid(groupId, packetData); + var id = br.ReadUInt32(); + _cluster.GroupRequestUpdate(id); return null; } - case InteropMethodId.ClusterBroadcastGuild: + // ---- 3. Control RPC ----------------------------------------- + case InteropMethodId.ControlWorldHello: { - var guildId = br.ReadInt64(); - var packetData = InteropSerializer.ReadByteArray(br); - _cluster.BroadcastGuild(guildId, packetData); - return null; + var uri = br.ReadString(); + var maps = InteropSerializer.ReadUInt32List(br); + var result = _cluster.Connect(uri, maps, _worldProxy); + return new[] { result ? (byte)1 : (byte)0 }; } - case InteropMethodId.ClusterBroadcastGuildOfficers: + case InteropMethodId.ControlWorldGoodbye: { - var guildId = br.ReadInt64(); - var packetData = InteropSerializer.ReadByteArray(br); - _cluster.BroadcastGuildOfficers(guildId, packetData); + var uri = br.ReadString(); + var maps = InteropSerializer.ReadUInt32List(br); + _cluster.Disconnect(uri, maps); return null; } - case InteropMethodId.ClusterGroupRequestUpdate: + case InteropMethodId.ControlGetCryptKey: { var id = br.ReadUInt32(); - _cluster.GroupRequestUpdate(id); + var key = _cluster.ClientGetCryptKey(id); + return InteropSerializer.WriteByteArray(key); + } + + case InteropMethodId.ControlBattlefieldList: + { + var type = br.ReadByte(); + var list = _cluster.BattlefieldList(type); + return InteropSerializer.WriteInt32List(list); + } + + case InteropMethodId.ControlBattlefieldFinish: + { + var battlefieldId = br.ReadInt32(); + _cluster.BattlefieldFinish(battlefieldId); return null; } + case InteropMethodId.ControlRunAdminCommand: + { + var cmdBytes = InteropSerializer.ReadByteArray(br); + var reply = _cluster.RunAdminCommand(cmdBytes); + return InteropSerializer.WriteByteArray(reply); + } + + case InteropMethodId.ControlRouteFederatedChat: + { + var realm = br.ReadUInt32(); + var env = InteropSerializer.ReadByteArray(br); + _cluster.RouteFederatedChat(realm, env); + return null; + } + + case InteropMethodId.ControlRouteFederatedGroupInvite: + { + var realm = br.ReadUInt32(); + var env = InteropSerializer.ReadByteArray(br); + _cluster.RouteFederatedGroupInvite(realm, env); + return null; + } + + case InteropMethodId.ControlQueryShard: + { + var mapId = br.ReadUInt32(); + var characterGuid = br.ReadUInt64(); + var result = _cluster.QueryShard(mapId, characterGuid); + return InteropSerializer.WriteShardLookupResult(result); + } + default: return null; } diff --git a/src/server/Mangos.Cluster.Interop/Dispatchers/WorldInteropDispatcher.cs b/src/server/Mangos.Cluster.Interop/Dispatchers/WorldInteropDispatcher.cs index 8cbaf5c5..02436883 100644 --- a/src/server/Mangos.Cluster.Interop/Dispatchers/WorldInteropDispatcher.cs +++ b/src/server/Mangos.Cluster.Interop/Dispatchers/WorldInteropDispatcher.cs @@ -21,8 +21,14 @@ namespace Mangos.Cluster.Interop.Dispatchers; /// -/// Dispatches incoming IWorld method calls from the cluster TCP connection -/// to the real IWorld implementation on the world server side. +/// World-side dispatcher: turns inbound envelopes from the cluster into +/// IWorld method calls. +/// +/// Envelope organization mirrors : +/// 1. Relay: ClientAttach/Detach/Login/Logout/PacketIn (client lifecycle +/// and decrypted packet stream). +/// 2. Control RPC: heartbeats, instance lifecycle, character creation, +/// group/guild/battlefield orchestration. /// public sealed class WorldInteropDispatcher { @@ -40,7 +46,8 @@ public WorldInteropDispatcher(IWorld world) switch (methodId) { - case InteropMethodId.WorldClientConnect: + // ---- 1. Relay ----------------------------------------------- + case InteropMethodId.ClientAttach: { var id = br.ReadUInt32(); var clientInfo = InteropSerializer.ReadClientInfo(br); @@ -48,14 +55,14 @@ public WorldInteropDispatcher(IWorld world) return null; } - case InteropMethodId.WorldClientDisconnect: + case InteropMethodId.ClientDetach: { var id = br.ReadUInt32(); _world.ClientDisconnect(id); return null; } - case InteropMethodId.WorldClientLogin: + case InteropMethodId.ClientLogin: { var id = br.ReadUInt32(); var guid = br.ReadUInt64(); @@ -63,42 +70,23 @@ public WorldInteropDispatcher(IWorld world) return null; } - case InteropMethodId.WorldClientLogout: + case InteropMethodId.ClientLogout: { var id = br.ReadUInt32(); _world.ClientLogout(id); return null; } - case InteropMethodId.WorldClientPacket: + case InteropMethodId.PacketIn: { var id = br.ReadUInt32(); - var packetData = InteropSerializer.ReadByteArray(br); - _world.ClientPacket(id, packetData); + var packet = InteropSerializer.ReadByteArray(br); + _world.ClientPacket(id, packet); return null; } - case InteropMethodId.WorldClientCreateCharacter: - { - var account = br.ReadString(); - var name = br.ReadString(); - var race = br.ReadByte(); - var classe = br.ReadByte(); - var gender = br.ReadByte(); - var skin = br.ReadByte(); - var face = br.ReadByte(); - var hairStyle = br.ReadByte(); - var hairColor = br.ReadByte(); - var facialHair = br.ReadByte(); - var outfitId = br.ReadByte(); - var result = _world.ClientCreateCharacter(account, name, race, classe, gender, skin, face, hairStyle, hairColor, facialHair, outfitId); - using var rms = new MemoryStream(); - using var bw = new BinaryWriter(rms); - bw.Write(result); - return rms.ToArray(); - } - - case InteropMethodId.WorldPing: + // ---- 2. Control RPC ----------------------------------------- + case InteropMethodId.ControlPing: { var timestamp = br.ReadInt32(); var latency = br.ReadInt32(); @@ -109,34 +97,54 @@ public WorldInteropDispatcher(IWorld world) return rms.ToArray(); } - case InteropMethodId.WorldGetServerInfo: + case InteropMethodId.ControlGetServerInfo: { var info = _world.GetServerInfo(); return InteropSerializer.WriteServerInfo(info); } - case InteropMethodId.WorldInstanceCreateAsync: + case InteropMethodId.ControlInstanceCreate: { var mapId = br.ReadUInt32(); await _world.InstanceCreateAsync(mapId); return Array.Empty(); } - case InteropMethodId.WorldInstanceDestroy: + case InteropMethodId.ControlInstanceDestroy: { var mapId = br.ReadUInt32(); _world.InstanceDestroy(mapId); return null; } - case InteropMethodId.WorldInstanceCanCreate: + case InteropMethodId.ControlInstanceCanCreate: { var type = br.ReadInt32(); var result = _world.InstanceCanCreate(type); return new[] { result ? (byte)1 : (byte)0 }; } - case InteropMethodId.WorldClientSetGroup: + case InteropMethodId.ControlClientCreateCharacter: + { + var account = br.ReadString(); + var name = br.ReadString(); + var race = br.ReadByte(); + var classe = br.ReadByte(); + var gender = br.ReadByte(); + var skin = br.ReadByte(); + var face = br.ReadByte(); + var hairStyle = br.ReadByte(); + var hairColor = br.ReadByte(); + var facialHair = br.ReadByte(); + var outfitId = br.ReadByte(); + var result = _world.ClientCreateCharacter(account, name, race, classe, gender, skin, face, hairStyle, hairColor, facialHair, outfitId); + using var rms = new MemoryStream(); + using var bw = new BinaryWriter(rms); + bw.Write(result); + return rms.ToArray(); + } + + case InteropMethodId.ControlClientSetGroup: { var id = br.ReadUInt32(); var groupId = br.ReadInt64(); @@ -144,7 +152,7 @@ public WorldInteropDispatcher(IWorld world) return null; } - case InteropMethodId.WorldGroupUpdate: + case InteropMethodId.ControlGroupUpdate: { var groupId = br.ReadInt64(); var groupType = br.ReadByte(); @@ -154,7 +162,7 @@ public WorldInteropDispatcher(IWorld world) return null; } - case InteropMethodId.WorldGroupUpdateLoot: + case InteropMethodId.ControlGroupUpdateLoot: { var groupId = br.ReadInt64(); var difficulty = br.ReadByte(); @@ -165,7 +173,7 @@ public WorldInteropDispatcher(IWorld world) return null; } - case InteropMethodId.WorldGroupMemberStats: + case InteropMethodId.ControlGroupMemberStats: { var guid = br.ReadUInt64(); var flag = br.ReadInt32(); @@ -173,7 +181,7 @@ public WorldInteropDispatcher(IWorld world) return InteropSerializer.WriteByteArray(stats); } - case InteropMethodId.WorldGuildUpdate: + case InteropMethodId.ControlGuildUpdate: { var guid = br.ReadUInt64(); var guildId = br.ReadUInt32(); @@ -182,7 +190,7 @@ public WorldInteropDispatcher(IWorld world) return null; } - case InteropMethodId.WorldBattlefieldCreate: + case InteropMethodId.ControlBattlefieldCreate: { var battlefieldId = br.ReadInt32(); var battlefieldMapType = br.ReadByte(); @@ -191,14 +199,14 @@ public WorldInteropDispatcher(IWorld world) return null; } - case InteropMethodId.WorldBattlefieldDelete: + case InteropMethodId.ControlBattlefieldDelete: { var battlefieldId = br.ReadInt32(); _world.BattlefieldDelete(battlefieldId); return null; } - case InteropMethodId.WorldBattlefieldJoin: + case InteropMethodId.ControlBattlefieldJoin: { var battlefieldId = br.ReadInt32(); var guid = br.ReadUInt64(); @@ -206,7 +214,7 @@ public WorldInteropDispatcher(IWorld world) return null; } - case InteropMethodId.WorldBattlefieldLeave: + case InteropMethodId.ControlBattlefieldLeave: { var battlefieldId = br.ReadInt32(); var guid = br.ReadUInt64(); diff --git a/src/server/Mangos.Cluster.Interop/ExitCodes.cs b/src/server/Mangos.Cluster.Interop/ExitCodes.cs new file mode 100644 index 00000000..c0034dfe --- /dev/null +++ b/src/server/Mangos.Cluster.Interop/ExitCodes.cs @@ -0,0 +1,50 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +namespace Mangos.Cluster.Interop; + +/// +/// Shared exit-code conventions for cluster and world processes. The +/// supervisor on the other side reads these to decide whether to respawn. +/// +/// Codes are deliberately disjoint and small so they can be matched by +/// systemd Restart=on-failure rules or container orchestrators. +/// +public static class ExitCodes +{ + /// Clean shutdown requested by operator. Supervisor will not respawn. + public const int Clean = 0; + + /// Configuration was invalid or missing. Supervisor will not respawn until config changes. + public const int ConfigInvalid = 2; + + /// A required database is at the wrong schema version. No respawn. + public const int DatabaseVersionMismatch = 3; + + /// Peer (cluster or world) requested a restart. Supervisor respawns immediately. + public const int RestartRequested = 10; + + /// Peer requested a stop (hard). Supervisor will not respawn. + public const int StopRequested = 11; + + /// Unhandled fatal exception. Supervisor respawns with backoff. + public const int FatalCrash = 20; + + /// Cluster connection lost and grace period expired with no clients. Respawn when peer recovers. + public const int Orphaned = 30; +} diff --git a/src/server/Mangos.Cluster.Interop/ICluster.cs b/src/server/Mangos.Cluster.Interop/ICluster.cs index 502358ea..026dbd49 100644 --- a/src/server/Mangos.Cluster.Interop/ICluster.cs +++ b/src/server/Mangos.Cluster.Interop/ICluster.cs @@ -21,51 +21,81 @@ namespace Mangos.Cluster.Interop; +/// +/// Surface a world server uses to talk to its cluster. +/// +/// On the wire (see ) the calls are +/// grouped into three buckets. Methods are listed below in the same order: +/// +/// 1. Relay (PacketOut): hot-path packet flow back to a client. +/// 2. Directives: fire-and-forget actions the cluster performs on +/// behalf of a world (drops, transfers, broadcasts, group fanout). +/// 3. Control RPC: registration and request/response queries. +/// +/// Methods keep their historical names so existing call sites stay stable. +/// public interface ICluster { - [Description("Signal realm server for new world server.")] - bool Connect(string uri, List maps, IWorld world); - - [Description("Signal realm server for disconected world server.")] - void Disconnect(string uri, List maps); - - [Description("Send data packet to client.")] + // ---- 1. Relay ---------------------------------------------------------- + [Description("Relay: send a packet to a specific client.")] void ClientSend(uint id, byte[] data); - [Description("Notify client drop.")] + // ---- 2. Directives (fire-and-forget) ---------------------------------- + [Description("Directive: drop the named client's connection.")] void ClientDrop(uint id); - [Description("Notify client transfer.")] + [Description("Directive: notify a client transfer.")] void ClientTransfer(uint id, float posX, float posY, float posZ, float ori, uint map); - [Description("Notify client update.")] + [Description("Directive: client zone/level update.")] void ClientUpdate(uint id, uint zone, byte level); - [Description("Set client chat flag.")] + [Description("Directive: set a client's chat flag.")] void ClientSetChatFlag(uint id, byte flag); - [Description("Get client crypt key.")] - byte[] ClientGetCryptKey(uint id); - - List BattlefieldList(byte type); - - void BattlefieldFinish(int battlefieldId); - - [Description("Send data packet to all clients online.")] + [Description("Directive: send a packet to all online clients.")] void Broadcast(byte[] data); - [Description("Send data packet to all clients in specified client's group.")] + [Description("Directive: send a packet to all clients in a group.")] void BroadcastGroup(long groupId, byte[] data); - [Description("Send data packet to all clients in specified client's raid.")] + [Description("Directive: send a packet to all clients in a raid.")] void BroadcastRaid(long groupId, byte[] data); - [Description("Send data packet to all clients in specified client's guild.")] + [Description("Directive: send a packet to all online members of a guild.")] void BroadcastGuild(long guildId, byte[] data); - [Description("Send data packet to all clients in specified client's guild officers.")] + [Description("Directive: send a packet to all online officers of a guild.")] void BroadcastGuildOfficers(long guildId, byte[] data); - [Description("Send update for the requested group.")] + [Description("Directive: ask the cluster to push a fresh group payload to a client.")] void GroupRequestUpdate(uint id); + + // ---- 3. Control RPC (request/response) -------------------------------- + [Description("Control: register this world's claim on the given maps.")] + bool Connect(string uri, List maps, IWorld world); + + [Description("Control: deregister this world's claim on the given maps.")] + void Disconnect(string uri, List maps); + + [Description("Control: read the current crypt key for a client.")] + byte[] ClientGetCryptKey(uint id); + + [Description("Control: list active battlefield ids of the given type.")] + List BattlefieldList(byte type); + + [Description("Control: a battlefield has finished.")] + void BattlefieldFinish(int battlefieldId); + + [Description("Control: run an admin command on the cluster (or route to a peer); returns serialized AdminCommandReply.")] + byte[] RunAdminCommand(byte[] commandBytes); + + [Description("Control: hand the cluster a cross-realm chat envelope so it can be routed to the destination peer.")] + void RouteFederatedChat(uint targetRealmId, byte[] chatEnvelope); + + [Description("Control: hand the cluster a cross-realm group invite so it can be routed to the destination peer.")] + void RouteFederatedGroupInvite(uint targetRealmId, byte[] inviteEnvelope); + + [Description("Control: Phase B shard lookup. Before loading a map, world asks whether a federated shard claims it for this character.")] + ShardLookupResult QueryShard(uint mapId, ulong characterGuid); } diff --git a/src/server/Mangos.Cluster.Interop/IWorld.cs b/src/server/Mangos.Cluster.Interop/IWorld.cs index e5108842..97833907 100644 --- a/src/server/Mangos.Cluster.Interop/IWorld.cs +++ b/src/server/Mangos.Cluster.Interop/IWorld.cs @@ -21,61 +21,81 @@ namespace Mangos.Cluster.Interop; +/// +/// Surface the cluster uses to talk to a world server. +/// +/// On the wire (see ) the calls are +/// grouped into two buckets. Methods are listed below in the same order: +/// +/// 1. Relay: per-client lifecycle and inbound WoW packet stream. The +/// cluster decrypts each packet on its end and forwards as a one-way +/// envelope; the world hands the bytes to its opcode dispatcher. +/// 2. Control RPC: heartbeats, instance lifecycle, character creation, +/// group/guild/battlefield orchestration. Request/response. +/// +/// Methods keep their historical names so existing call sites stay stable. +/// public interface IWorld { - [Description("Initialize client object.")] + // ---- 1. Relay ---------------------------------------------------------- + [Description("Relay: cluster has accepted a new client; world sets up its session state.")] void ClientConnect(uint id, ClientInfo client); - [Description("Destroy client object.")] + [Description("Relay: client is gone; world tears down its session state.")] void ClientDisconnect(uint id); - [Description("Assing particular client to this world server (Use client ID).")] + [Description("Relay: client picked a character.")] void ClientLogin(uint id, ulong guid); - [Description("Remove particular client from this world server (Use client ID).")] + [Description("Relay: client logged out of their character.")] void ClientLogout(uint id); - [Description("Transfer packet from Realm to World using client's ID.")] + [Description("Relay: a decrypted WoW packet from the client.")] void ClientPacket(uint id, byte[] data); - [Description("Create CharacterObject.")] - int ClientCreateCharacter(string account, string name, byte race, byte classe, byte gender, byte skin, byte face, byte hairStyle, byte hairColor, byte facialHair, byte outfitId); - - [Description("Respond to world server if still alive.")] + // ---- 2. Control RPC (request/response) -------------------------------- + [Description("Control: heartbeat. Returns the world's tick.")] int Ping(int timestamp, int latency); - [Description("Tell the cluster about your CPU & Memory Usage")] + [Description("Control: collect CPU/memory/load snapshot for the supervisor.")] ServerInfo GetServerInfo(); - [Description("Make world create specific map.")] + [Description("Control: load and host an instance of the given map.")] Task InstanceCreateAsync(uint Map); - [Description("Make world destroy specific map.")] + [Description("Control: tear down an instance of the given map.")] void InstanceDestroy(uint Map); - [Description("Check world configuration.")] + [Description("Control: ask whether this world can host a new instance of the given type.")] bool InstanceCanCreate(int Type); - [Description("Set client's group.")] + [Description("Control: create a character for the named account.")] + int ClientCreateCharacter(string account, string name, byte race, byte classe, byte gender, byte skin, byte face, byte hairStyle, byte hairColor, byte facialHair, byte outfitId); + + [Description("Control: associate a client with a group id.")] void ClientSetGroup(uint ID, long GroupID); - [Description("Update group information.")] + [Description("Control: refresh a group's roster/leader on this world.")] void GroupUpdate(long GroupID, byte GroupType, ulong GroupLeader, ulong[] Members); - [Description("Update group information about looting.")] + [Description("Control: refresh a group's loot rules on this world.")] void GroupUpdateLoot(long GroupID, byte Difficulty, byte Method, byte Threshold, ulong Master); - [Description("Request party member stats.")] + [Description("Control: read a character's groupable stats payload.")] byte[] GroupMemberStats(ulong GUID, int Flag); - [Description("Update guild information.")] + [Description("Control: refresh guild membership for a character.")] void GuildUpdate(ulong GUID, uint GuildID, byte GuildRank); + [Description("Control: spin up battlefield bookkeeping.")] void BattlefieldCreate(int BattlefieldID, byte BattlefieldMapType, uint Map); + [Description("Control: tear down a battlefield.")] void BattlefieldDelete(int BattlefieldID); + [Description("Control: a character joined a battlefield.")] void BattlefieldJoin(int BattlefieldID, ulong GUID); + [Description("Control: a character left a battlefield.")] void BattlefieldLeave(int BattlefieldID, ulong GUID); } diff --git a/src/server/Mangos.Cluster.Interop/Protocol/InteropMethodId.cs b/src/server/Mangos.Cluster.Interop/Protocol/InteropMethodId.cs index fd48e348..5a1a125a 100644 --- a/src/server/Mangos.Cluster.Interop/Protocol/InteropMethodId.cs +++ b/src/server/Mangos.Cluster.Interop/Protocol/InteropMethodId.cs @@ -19,52 +19,92 @@ namespace Mangos.Cluster.Interop.Protocol; /// -/// Method IDs for binary RPC calls between cluster and world servers. -/// ICluster methods (called by World → Cluster) use range 0x0001-0x00FF. -/// IWorld methods (called by Cluster → World) use range 0x0101-0x01FF. +/// Wire-level method identifiers for the cluster <-> world interop link. +/// +/// The protocol is organized into three buckets so the cluster can act +/// as a packet proxy rather than a 30+ method RPC server: +/// +/// 1. Client relay (0x0200-0x020F): per-client lifecycle and the WoW +/// packet stream. This is the hot path - the vast majority of traffic. +/// Cluster receives, decrypts, then forwards inbound packets to the +/// world that owns that client. Outbound packets travel the same way +/// in reverse. +/// +/// 2. Cluster directives (0x0210-0x021F): fire-and-forget instructions +/// from a world to the cluster ("send this to that client", "drop +/// this client", "broadcast this to a group"). The world never +/// learns about clients beyond the ids the cluster gave it. +/// +/// 3. Control RPC (0x0220-0x023F): request/response control plane for +/// things that cannot be fire-and-forget: registration, instance +/// spawning, character creation, party stats. Bidirectional - both +/// sides can originate calls. +/// +/// 0xFFFF is reserved for the framing layer to mark response frames. +/// +/// The 0x02xx range deliberately leaves room above (0x0300+) for the +/// federation channel introduced in PR #4 (cluster <-> cluster). /// public enum InteropMethodId : ushort { - // ICluster methods (World → Cluster) - ClusterConnect = 0x0001, - ClusterDisconnect = 0x0002, - ClusterClientSend = 0x0003, - ClusterClientDrop = 0x0004, - ClusterClientTransfer = 0x0005, - ClusterClientUpdate = 0x0006, - ClusterClientSetChatFlag = 0x0007, - ClusterClientGetCryptKey = 0x0008, - ClusterBattlefieldList = 0x0009, - ClusterBattlefieldFinish = 0x000A, - ClusterBroadcast = 0x000B, - ClusterBroadcastGroup = 0x000C, - ClusterBroadcastRaid = 0x000D, - ClusterBroadcastGuild = 0x000E, - ClusterBroadcastGuildOfficers = 0x000F, - ClusterGroupRequestUpdate = 0x0010, + // ----- Client relay (cluster <-> world) ---------------------------- + ClientAttach = 0x0200, + ClientDetach = 0x0201, + ClientLogin = 0x0202, + ClientLogout = 0x0203, + PacketIn = 0x0204, // cluster -> world (decrypted client packet) + PacketOut = 0x0205, // world -> cluster (packet to send to client) - // IWorld methods (Cluster → World) - WorldClientConnect = 0x0101, - WorldClientDisconnect = 0x0102, - WorldClientLogin = 0x0103, - WorldClientLogout = 0x0104, - WorldClientPacket = 0x0105, - WorldClientCreateCharacter = 0x0106, - WorldPing = 0x0107, - WorldGetServerInfo = 0x0108, - WorldInstanceCreateAsync = 0x0109, - WorldInstanceDestroy = 0x010A, - WorldInstanceCanCreate = 0x010B, - WorldClientSetGroup = 0x010C, - WorldGroupUpdate = 0x010D, - WorldGroupUpdateLoot = 0x010E, - WorldGroupMemberStats = 0x010F, - WorldGuildUpdate = 0x0110, - WorldBattlefieldCreate = 0x0111, - WorldBattlefieldDelete = 0x0112, - WorldBattlefieldJoin = 0x0113, - WorldBattlefieldLeave = 0x0114, + // ----- Cluster directives (world -> cluster) ----------------------- + DirectiveDropClient = 0x0210, + DirectiveTransferClient = 0x0211, + DirectiveUpdateClient = 0x0212, + DirectiveSetClientChatFlag = 0x0213, + DirectiveBroadcast = 0x0214, + DirectiveBroadcastGroup = 0x0215, + DirectiveBroadcastRaid = 0x0216, + DirectiveBroadcastGuild = 0x0217, + DirectiveBroadcastGuildOfficers = 0x0218, + DirectiveGroupRequestUpdate = 0x0219, - // Protocol-level + // ----- Control RPC (bidirectional) --------------------------------- + // World -> cluster + ControlWorldHello = 0x0220, + ControlWorldGoodbye = 0x0221, + ControlGetCryptKey = 0x0222, + ControlBattlefieldList = 0x0223, + ControlBattlefieldFinish = 0x0224, + + // Cluster -> world + ControlPing = 0x0230, + ControlGetServerInfo = 0x0231, + ControlInstanceCreate = 0x0232, + ControlInstanceDestroy = 0x0233, + ControlInstanceCanCreate = 0x0234, + ControlClientCreateCharacter = 0x0235, + ControlClientSetGroup = 0x0236, + ControlGroupUpdate = 0x0237, + ControlGroupUpdateLoot = 0x0238, + ControlGroupMemberStats = 0x0239, + ControlGuildUpdate = 0x023A, + ControlBattlefieldCreate = 0x023B, + ControlBattlefieldDelete = 0x023C, + ControlBattlefieldJoin = 0x023D, + ControlBattlefieldLeave = 0x023E, + + // World -> cluster (admin) + ControlRunAdminCommand = 0x0240, + + // World -> cluster (federation gateway). World hands the cluster a + // serialized cross-realm envelope; cluster routes via FederationRouter. + ControlRouteFederatedChat = 0x0241, + ControlRouteFederatedGroupInvite = 0x0242, + + // World -> cluster (Phase B shard lookup). World asks before loading + // an instance whether a federated shard claims this map for this + // character; the cluster consults its ShardRegistry. + ControlQueryShard = 0x0243, + + // ----- Framing ----------------------------------------------------- Response = 0xFFFF, } diff --git a/src/server/Mangos.Cluster.Interop/Protocol/InteropSerializer.cs b/src/server/Mangos.Cluster.Interop/Protocol/InteropSerializer.cs index a65539e5..15e12bec 100644 --- a/src/server/Mangos.Cluster.Interop/Protocol/InteropSerializer.cs +++ b/src/server/Mangos.Cluster.Interop/Protocol/InteropSerializer.cs @@ -66,6 +66,10 @@ public static byte[] WriteServerInfo(ServerInfo info) using var bw = new BinaryWriter(ms); bw.Write(info.CpuUsage); bw.Write(info.MemoryUsage); + bw.Write(info.PlayerCount); + bw.Write(info.InstanceCount); + bw.Write(info.BattlegroundCount); + bw.Write(info.UptimeMs); return ms.ToArray(); } @@ -74,7 +78,11 @@ public static ServerInfo ReadServerInfo(BinaryReader br) return new ServerInfo { CpuUsage = br.ReadSingle(), - MemoryUsage = br.ReadUInt64() + MemoryUsage = br.ReadUInt64(), + PlayerCount = br.ReadInt32(), + InstanceCount = br.ReadInt32(), + BattlegroundCount = br.ReadInt32(), + UptimeMs = br.ReadInt64() }; } @@ -161,4 +169,26 @@ public static byte[] ReadByteArray(BinaryReader br) var length = br.ReadInt32(); return br.ReadBytes(length); } + + public static byte[] WriteShardLookupResult(ShardLookupResult result) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write((byte)result.Kind); + bw.Write(result.OwnerClusterId); + bw.Write(result.OwnerEndpoint ?? string.Empty); + bw.Write(result.OwnerDisplayTag ?? string.Empty); + return ms.ToArray(); + } + + public static ShardLookupResult ReadShardLookupResult(BinaryReader br) + { + return new ShardLookupResult + { + Kind = (ShardLookupKind)br.ReadByte(), + OwnerClusterId = br.ReadUInt32(), + OwnerEndpoint = br.ReadString(), + OwnerDisplayTag = br.ReadString(), + }; + } } diff --git a/src/server/Mangos.Cluster.Interop/Proxies/ClusterInteropProxy.cs b/src/server/Mangos.Cluster.Interop/Proxies/ClusterInteropProxy.cs index 2fd0d9ae..076b5a6b 100644 --- a/src/server/Mangos.Cluster.Interop/Proxies/ClusterInteropProxy.cs +++ b/src/server/Mangos.Cluster.Interop/Proxies/ClusterInteropProxy.cs @@ -21,8 +21,12 @@ namespace Mangos.Cluster.Interop.Proxies; /// -/// ICluster proxy that serializes method calls over TCP to the cluster server. -/// Used by the world server to communicate with a remote cluster. +/// World-side stub of ICluster: each method serializes its arguments into +/// the appropriate envelope and ships it down the IPC connection. +/// +/// Layout matches : relay (PacketOut), +/// directives (drop/transfer/update/chat-flag/broadcast/group), control RPC +/// (world hello/goodbye, crypt-key, battlefield list/finish). /// public sealed class ClusterInteropProxy : ICluster { @@ -33,41 +37,7 @@ public ClusterInteropProxy(InteropConnection connection) _connection = connection; } - public bool Connect(string uri, List maps, IWorld world) - { - // The IWorld reference is implicit (this TCP connection IS the world server). - // We only send the URI and maps. The cluster creates a WorldInteropProxy on its end. - using var ms = new MemoryStream(); - using var bw = new BinaryWriter(ms); - bw.Write(uri); - bw.Write(maps.Count); - foreach (var map in maps) - { - bw.Write(map); - } - - var response = _connection.SendRequestAsync(InteropMethodId.ClusterConnect, ms.ToArray()).GetAwaiter().GetResult(); - if (response.Length >= 1) - { - return response[0] != 0; - } - return false; - } - - public void Disconnect(string uri, List maps) - { - using var ms = new MemoryStream(); - using var bw = new BinaryWriter(ms); - bw.Write(uri); - bw.Write(maps.Count); - foreach (var map in maps) - { - bw.Write(map); - } - - _connection.SendOneWayAsync(InteropMethodId.ClusterDisconnect, ms.ToArray()).GetAwaiter().GetResult(); - } - + // ---- 1. Relay -------------------------------------------------------- public void ClientSend(uint id, byte[] data) { using var ms = new MemoryStream(); @@ -76,16 +46,17 @@ public void ClientSend(uint id, byte[] data) bw.Write(data.Length); bw.Write(data); - _connection.SendOneWayAsync(InteropMethodId.ClusterClientSend, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.PacketOut, ms.ToArray()).GetAwaiter().GetResult(); } + // ---- 2. Directives --------------------------------------------------- public void ClientDrop(uint id) { using var ms = new MemoryStream(); using var bw = new BinaryWriter(ms); bw.Write(id); - _connection.SendOneWayAsync(InteropMethodId.ClusterClientDrop, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.DirectiveDropClient, ms.ToArray()).GetAwaiter().GetResult(); } public void ClientTransfer(uint id, float posX, float posY, float posZ, float ori, uint map) @@ -99,7 +70,7 @@ public void ClientTransfer(uint id, float posX, float posY, float posZ, float or bw.Write(ori); bw.Write(map); - _connection.SendOneWayAsync(InteropMethodId.ClusterClientTransfer, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.DirectiveTransferClient, ms.ToArray()).GetAwaiter().GetResult(); } public void ClientUpdate(uint id, uint zone, byte level) @@ -110,7 +81,7 @@ public void ClientUpdate(uint id, uint zone, byte level) bw.Write(zone); bw.Write(level); - _connection.SendOneWayAsync(InteropMethodId.ClusterClientUpdate, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.DirectiveUpdateClient, ms.ToArray()).GetAwaiter().GetResult(); } public void ClientSetChatFlag(uint id, byte flag) @@ -120,41 +91,12 @@ public void ClientSetChatFlag(uint id, byte flag) bw.Write(id); bw.Write(flag); - _connection.SendOneWayAsync(InteropMethodId.ClusterClientSetChatFlag, ms.ToArray()).GetAwaiter().GetResult(); - } - - public byte[] ClientGetCryptKey(uint id) - { - using var ms = new MemoryStream(); - using var bw = new BinaryWriter(ms); - bw.Write(id); - - var response = _connection.SendRequestAsync(InteropMethodId.ClusterClientGetCryptKey, ms.ToArray()).GetAwaiter().GetResult(); - using var rms = new MemoryStream(response); - using var br = new BinaryReader(rms); - return InteropSerializer.ReadByteArray(br); - } - - public List BattlefieldList(byte type) - { - var response = _connection.SendRequestAsync(InteropMethodId.ClusterBattlefieldList, new[] { type }).GetAwaiter().GetResult(); - using var ms = new MemoryStream(response); - using var br = new BinaryReader(ms); - return InteropSerializer.ReadInt32List(br); - } - - public void BattlefieldFinish(int battlefieldId) - { - using var ms = new MemoryStream(); - using var bw = new BinaryWriter(ms); - bw.Write(battlefieldId); - - _connection.SendOneWayAsync(InteropMethodId.ClusterBattlefieldFinish, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.DirectiveSetClientChatFlag, ms.ToArray()).GetAwaiter().GetResult(); } public void Broadcast(byte[] data) { - _connection.SendOneWayAsync(InteropMethodId.ClusterBroadcast, InteropSerializer.WriteByteArray(data)).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.DirectiveBroadcast, InteropSerializer.WriteByteArray(data)).GetAwaiter().GetResult(); } public void BroadcastGroup(long groupId, byte[] data) @@ -165,7 +107,7 @@ public void BroadcastGroup(long groupId, byte[] data) bw.Write(data.Length); bw.Write(data); - _connection.SendOneWayAsync(InteropMethodId.ClusterBroadcastGroup, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.DirectiveBroadcastGroup, ms.ToArray()).GetAwaiter().GetResult(); } public void BroadcastRaid(long groupId, byte[] data) @@ -176,7 +118,7 @@ public void BroadcastRaid(long groupId, byte[] data) bw.Write(data.Length); bw.Write(data); - _connection.SendOneWayAsync(InteropMethodId.ClusterBroadcastRaid, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.DirectiveBroadcastRaid, ms.ToArray()).GetAwaiter().GetResult(); } public void BroadcastGuild(long guildId, byte[] data) @@ -187,7 +129,7 @@ public void BroadcastGuild(long guildId, byte[] data) bw.Write(data.Length); bw.Write(data); - _connection.SendOneWayAsync(InteropMethodId.ClusterBroadcastGuild, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.DirectiveBroadcastGuild, ms.ToArray()).GetAwaiter().GetResult(); } public void BroadcastGuildOfficers(long guildId, byte[] data) @@ -198,7 +140,7 @@ public void BroadcastGuildOfficers(long guildId, byte[] data) bw.Write(data.Length); bw.Write(data); - _connection.SendOneWayAsync(InteropMethodId.ClusterBroadcastGuildOfficers, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.DirectiveBroadcastGuildOfficers, ms.ToArray()).GetAwaiter().GetResult(); } public void GroupRequestUpdate(uint id) @@ -207,6 +149,109 @@ public void GroupRequestUpdate(uint id) using var bw = new BinaryWriter(ms); bw.Write(id); - _connection.SendOneWayAsync(InteropMethodId.ClusterGroupRequestUpdate, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.DirectiveGroupRequestUpdate, ms.ToArray()).GetAwaiter().GetResult(); + } + + // ---- 3. Control RPC -------------------------------------------------- + public bool Connect(string uri, List maps, IWorld world) + { + // The IWorld parameter is implicit on the wire: the cluster builds + // its own IWorld stub from this connection in the dispatcher. + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(uri); + bw.Write(maps.Count); + foreach (var map in maps) + { + bw.Write(map); + } + + var response = _connection.SendRequestAsync(InteropMethodId.ControlWorldHello, ms.ToArray()).GetAwaiter().GetResult(); + return response.Length >= 1 && response[0] != 0; + } + + public void Disconnect(string uri, List maps) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(uri); + bw.Write(maps.Count); + foreach (var map in maps) + { + bw.Write(map); + } + + _connection.SendOneWayAsync(InteropMethodId.ControlWorldGoodbye, ms.ToArray()).GetAwaiter().GetResult(); + } + + public byte[] ClientGetCryptKey(uint id) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(id); + + var response = _connection.SendRequestAsync(InteropMethodId.ControlGetCryptKey, ms.ToArray()).GetAwaiter().GetResult(); + using var rms = new MemoryStream(response); + using var br = new BinaryReader(rms); + return InteropSerializer.ReadByteArray(br); + } + + public List BattlefieldList(byte type) + { + var response = _connection.SendRequestAsync(InteropMethodId.ControlBattlefieldList, new[] { type }).GetAwaiter().GetResult(); + using var ms = new MemoryStream(response); + using var br = new BinaryReader(ms); + return InteropSerializer.ReadInt32List(br); + } + + public void BattlefieldFinish(int battlefieldId) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(battlefieldId); + + _connection.SendOneWayAsync(InteropMethodId.ControlBattlefieldFinish, ms.ToArray()).GetAwaiter().GetResult(); + } + + public byte[] RunAdminCommand(byte[] commandBytes) + { + var response = _connection.SendRequestAsync( + InteropMethodId.ControlRunAdminCommand, + InteropSerializer.WriteByteArray(commandBytes)).GetAwaiter().GetResult(); + using var rms = new MemoryStream(response); + using var br = new BinaryReader(rms); + return InteropSerializer.ReadByteArray(br); + } + + public void RouteFederatedChat(uint targetRealmId, byte[] chatEnvelope) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(targetRealmId); + bw.Write(chatEnvelope.Length); + bw.Write(chatEnvelope); + _connection.SendOneWayAsync(InteropMethodId.ControlRouteFederatedChat, ms.ToArray()).GetAwaiter().GetResult(); + } + + public void RouteFederatedGroupInvite(uint targetRealmId, byte[] inviteEnvelope) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(targetRealmId); + bw.Write(inviteEnvelope.Length); + bw.Write(inviteEnvelope); + _connection.SendOneWayAsync(InteropMethodId.ControlRouteFederatedGroupInvite, ms.ToArray()).GetAwaiter().GetResult(); + } + + public ShardLookupResult QueryShard(uint mapId, ulong characterGuid) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(mapId); + bw.Write(characterGuid); + var response = _connection.SendRequestAsync(InteropMethodId.ControlQueryShard, ms.ToArray()).GetAwaiter().GetResult(); + using var rms = new MemoryStream(response); + using var br = new BinaryReader(rms); + return InteropSerializer.ReadShardLookupResult(br); } } diff --git a/src/server/Mangos.Cluster.Interop/Proxies/WorldInteropProxy.cs b/src/server/Mangos.Cluster.Interop/Proxies/WorldInteropProxy.cs index a62551a3..3a61be83 100644 --- a/src/server/Mangos.Cluster.Interop/Proxies/WorldInteropProxy.cs +++ b/src/server/Mangos.Cluster.Interop/Proxies/WorldInteropProxy.cs @@ -21,8 +21,13 @@ namespace Mangos.Cluster.Interop.Proxies; /// -/// IWorld proxy that serializes method calls over TCP to a world server. -/// Used by the cluster to communicate with remote world server processes. +/// Cluster-side stub of IWorld: each method serializes its arguments into +/// the appropriate envelope and ships it down the IPC connection to the +/// world server process. +/// +/// Layout matches : relay (attach/detach/login/ +/// logout/PacketIn) and control RPC (ping, server info, instance lifecycle, +/// character creation, group/guild/battlefield orchestration). /// public sealed class WorldInteropProxy : IWorld { @@ -33,6 +38,7 @@ public WorldInteropProxy(InteropConnection connection) _connection = connection; } + // ---- 1. Relay -------------------------------------------------------- public void ClientConnect(uint id, ClientInfo client) { using var ms = new MemoryStream(); @@ -45,7 +51,7 @@ public void ClientConnect(uint id, ClientInfo client) bw.Write((byte)client.Access); bw.Write((byte)client.Expansion); - _connection.SendOneWayAsync(InteropMethodId.WorldClientConnect, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ClientAttach, ms.ToArray()).GetAwaiter().GetResult(); } public void ClientDisconnect(uint id) @@ -54,7 +60,7 @@ public void ClientDisconnect(uint id) using var bw = new BinaryWriter(ms); bw.Write(id); - _connection.SendOneWayAsync(InteropMethodId.WorldClientDisconnect, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ClientDetach, ms.ToArray()).GetAwaiter().GetResult(); } public void ClientLogin(uint id, ulong guid) @@ -64,7 +70,7 @@ public void ClientLogin(uint id, ulong guid) bw.Write(id); bw.Write(guid); - _connection.SendOneWayAsync(InteropMethodId.WorldClientLogin, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ClientLogin, ms.ToArray()).GetAwaiter().GetResult(); } public void ClientLogout(uint id) @@ -73,7 +79,7 @@ public void ClientLogout(uint id) using var bw = new BinaryWriter(ms); bw.Write(id); - _connection.SendOneWayAsync(InteropMethodId.WorldClientLogout, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ClientLogout, ms.ToArray()).GetAwaiter().GetResult(); } public void ClientPacket(uint id, byte[] data) @@ -84,35 +90,10 @@ public void ClientPacket(uint id, byte[] data) bw.Write(data.Length); bw.Write(data); - _connection.SendOneWayAsync(InteropMethodId.WorldClientPacket, ms.ToArray()).GetAwaiter().GetResult(); - } - - public int ClientCreateCharacter(string account, string name, byte race, byte classe, byte gender, byte skin, byte face, byte hairStyle, byte hairColor, byte facialHair, byte outfitId) - { - using var ms = new MemoryStream(); - using var bw = new BinaryWriter(ms); - bw.Write(account); - bw.Write(name); - bw.Write(race); - bw.Write(classe); - bw.Write(gender); - bw.Write(skin); - bw.Write(face); - bw.Write(hairStyle); - bw.Write(hairColor); - bw.Write(facialHair); - bw.Write(outfitId); - - var response = _connection.SendRequestAsync(InteropMethodId.WorldClientCreateCharacter, ms.ToArray()).GetAwaiter().GetResult(); - if (response.Length >= 4) - { - using var rms = new MemoryStream(response); - using var br = new BinaryReader(rms); - return br.ReadInt32(); - } - return 0; + _connection.SendOneWayAsync(InteropMethodId.PacketIn, ms.ToArray()).GetAwaiter().GetResult(); } + // ---- 2. Control RPC -------------------------------------------------- public int Ping(int timestamp, int latency) { using var ms = new MemoryStream(); @@ -120,7 +101,7 @@ public int Ping(int timestamp, int latency) bw.Write(timestamp); bw.Write(latency); - var response = _connection.SendRequestAsync(InteropMethodId.WorldPing, ms.ToArray()).GetAwaiter().GetResult(); + var response = _connection.SendRequestAsync(InteropMethodId.ControlPing, ms.ToArray()).GetAwaiter().GetResult(); if (response.Length >= 4) { using var rms = new MemoryStream(response); @@ -132,7 +113,7 @@ public int Ping(int timestamp, int latency) public ServerInfo GetServerInfo() { - var response = _connection.SendRequestAsync(InteropMethodId.WorldGetServerInfo, Array.Empty()).GetAwaiter().GetResult(); + var response = _connection.SendRequestAsync(InteropMethodId.ControlGetServerInfo, Array.Empty()).GetAwaiter().GetResult(); using var ms = new MemoryStream(response); using var br = new BinaryReader(ms); return InteropSerializer.ReadServerInfo(br); @@ -144,7 +125,7 @@ public async Task InstanceCreateAsync(uint Map) using var bw = new BinaryWriter(ms); bw.Write(Map); - await _connection.SendRequestAsync(InteropMethodId.WorldInstanceCreateAsync, ms.ToArray()); + await _connection.SendRequestAsync(InteropMethodId.ControlInstanceCreate, ms.ToArray()); } public void InstanceDestroy(uint Map) @@ -153,7 +134,7 @@ public void InstanceDestroy(uint Map) using var bw = new BinaryWriter(ms); bw.Write(Map); - _connection.SendOneWayAsync(InteropMethodId.WorldInstanceDestroy, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ControlInstanceDestroy, ms.ToArray()).GetAwaiter().GetResult(); } public bool InstanceCanCreate(int Type) @@ -162,10 +143,36 @@ public bool InstanceCanCreate(int Type) using var bw = new BinaryWriter(ms); bw.Write(Type); - var response = _connection.SendRequestAsync(InteropMethodId.WorldInstanceCanCreate, ms.ToArray()).GetAwaiter().GetResult(); + var response = _connection.SendRequestAsync(InteropMethodId.ControlInstanceCanCreate, ms.ToArray()).GetAwaiter().GetResult(); return response.Length >= 1 && response[0] != 0; } + public int ClientCreateCharacter(string account, string name, byte race, byte classe, byte gender, byte skin, byte face, byte hairStyle, byte hairColor, byte facialHair, byte outfitId) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(account); + bw.Write(name); + bw.Write(race); + bw.Write(classe); + bw.Write(gender); + bw.Write(skin); + bw.Write(face); + bw.Write(hairStyle); + bw.Write(hairColor); + bw.Write(facialHair); + bw.Write(outfitId); + + var response = _connection.SendRequestAsync(InteropMethodId.ControlClientCreateCharacter, ms.ToArray()).GetAwaiter().GetResult(); + if (response.Length >= 4) + { + using var rms = new MemoryStream(response); + using var br = new BinaryReader(rms); + return br.ReadInt32(); + } + return 0; + } + public void ClientSetGroup(uint ID, long GroupID) { using var ms = new MemoryStream(); @@ -173,7 +180,7 @@ public void ClientSetGroup(uint ID, long GroupID) bw.Write(ID); bw.Write(GroupID); - _connection.SendOneWayAsync(InteropMethodId.WorldClientSetGroup, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ControlClientSetGroup, ms.ToArray()).GetAwaiter().GetResult(); } public void GroupUpdate(long GroupID, byte GroupType, ulong GroupLeader, ulong[] Members) @@ -189,7 +196,7 @@ public void GroupUpdate(long GroupID, byte GroupType, ulong GroupLeader, ulong[] bw.Write(m); } - _connection.SendOneWayAsync(InteropMethodId.WorldGroupUpdate, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ControlGroupUpdate, ms.ToArray()).GetAwaiter().GetResult(); } public void GroupUpdateLoot(long GroupID, byte Difficulty, byte Method, byte Threshold, ulong Master) @@ -202,7 +209,7 @@ public void GroupUpdateLoot(long GroupID, byte Difficulty, byte Method, byte Thr bw.Write(Threshold); bw.Write(Master); - _connection.SendOneWayAsync(InteropMethodId.WorldGroupUpdateLoot, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ControlGroupUpdateLoot, ms.ToArray()).GetAwaiter().GetResult(); } public byte[] GroupMemberStats(ulong GUID, int Flag) @@ -212,7 +219,7 @@ public byte[] GroupMemberStats(ulong GUID, int Flag) bw.Write(GUID); bw.Write(Flag); - var response = _connection.SendRequestAsync(InteropMethodId.WorldGroupMemberStats, ms.ToArray()).GetAwaiter().GetResult(); + var response = _connection.SendRequestAsync(InteropMethodId.ControlGroupMemberStats, ms.ToArray()).GetAwaiter().GetResult(); using var rms = new MemoryStream(response); using var br = new BinaryReader(rms); return InteropSerializer.ReadByteArray(br); @@ -226,7 +233,7 @@ public void GuildUpdate(ulong GUID, uint GuildID, byte GuildRank) bw.Write(GuildID); bw.Write(GuildRank); - _connection.SendOneWayAsync(InteropMethodId.WorldGuildUpdate, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ControlGuildUpdate, ms.ToArray()).GetAwaiter().GetResult(); } public void BattlefieldCreate(int BattlefieldID, byte BattlefieldMapType, uint Map) @@ -237,7 +244,7 @@ public void BattlefieldCreate(int BattlefieldID, byte BattlefieldMapType, uint M bw.Write(BattlefieldMapType); bw.Write(Map); - _connection.SendOneWayAsync(InteropMethodId.WorldBattlefieldCreate, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ControlBattlefieldCreate, ms.ToArray()).GetAwaiter().GetResult(); } public void BattlefieldDelete(int BattlefieldID) @@ -246,7 +253,7 @@ public void BattlefieldDelete(int BattlefieldID) using var bw = new BinaryWriter(ms); bw.Write(BattlefieldID); - _connection.SendOneWayAsync(InteropMethodId.WorldBattlefieldDelete, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ControlBattlefieldDelete, ms.ToArray()).GetAwaiter().GetResult(); } public void BattlefieldJoin(int BattlefieldID, ulong GUID) @@ -256,7 +263,7 @@ public void BattlefieldJoin(int BattlefieldID, ulong GUID) bw.Write(BattlefieldID); bw.Write(GUID); - _connection.SendOneWayAsync(InteropMethodId.WorldBattlefieldJoin, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ControlBattlefieldJoin, ms.ToArray()).GetAwaiter().GetResult(); } public void BattlefieldLeave(int BattlefieldID, ulong GUID) @@ -266,6 +273,6 @@ public void BattlefieldLeave(int BattlefieldID, ulong GUID) bw.Write(BattlefieldID); bw.Write(GUID); - _connection.SendOneWayAsync(InteropMethodId.WorldBattlefieldLeave, ms.ToArray()).GetAwaiter().GetResult(); + _connection.SendOneWayAsync(InteropMethodId.ControlBattlefieldLeave, ms.ToArray()).GetAwaiter().GetResult(); } } diff --git a/src/server/Mangos.Cluster.Interop/ServerInfo.cs b/src/server/Mangos.Cluster.Interop/ServerInfo.cs index 94dd2358..14b65a18 100644 --- a/src/server/Mangos.Cluster.Interop/ServerInfo.cs +++ b/src/server/Mangos.Cluster.Interop/ServerInfo.cs @@ -18,14 +18,46 @@ namespace Mangos.Cluster.Interop; +/// +/// Heartbeat payload reported by a world to its supervisor. Cheap to +/// produce, summarises the world's current load so the cluster can place +/// new instances/clients on the least-loaded eligible world. +/// public class ServerInfo { + /// 0.0-1.0 CPU usage, sampled over the last heartbeat interval. public float CpuUsage { get; set; } + + /// Resident memory in bytes. public ulong MemoryUsage { get; set; } + + /// Total online clients on this world. + public int PlayerCount + { + get; set; + } + + /// Active instances (continents + dungeons + raids + battlegrounds). + public int InstanceCount + { + get; set; + } + + /// Battlegrounds currently hosted. + public int BattlegroundCount + { + get; set; + } + + /// Milliseconds since this world process started. + public long UptimeMs + { + get; set; + } } diff --git a/src/server/Mangos.Cluster.Interop/ShardLookupResult.cs b/src/server/Mangos.Cluster.Interop/ShardLookupResult.cs new file mode 100644 index 00000000..fc3a1532 --- /dev/null +++ b/src/server/Mangos.Cluster.Interop/ShardLookupResult.cs @@ -0,0 +1,59 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +namespace Mangos.Cluster.Interop; + +/// +/// Result of : tells a world whether the +/// (mapId, characterGuid) pair belongs to a federated shard, and if so +/// whether the local cluster is the host or a foreign cluster owns it. +/// +public sealed class ShardLookupResult +{ + public required ShardLookupKind Kind + { + get; init; + } + + /// Owning cluster id when Kind == Foreign; 0 otherwise. + public uint OwnerClusterId + { + get; init; + } + + /// Host:port of the foreign cluster's federation listener; empty when Kind != Foreign. + public string OwnerEndpoint { get; init; } = string.Empty; + + /// Owner's display tag (for player-facing messages). + public string OwnerDisplayTag { get; init; } = string.Empty; + + public static readonly ShardLookupResult NoShard = new() { Kind = ShardLookupKind.NoShard }; + public static readonly ShardLookupResult Local = new() { Kind = ShardLookupKind.Local }; +} + +public enum ShardLookupKind : byte +{ + /// No federated shard claims this (mapId, characterGuid). Host normally. + NoShard = 0, + + /// This cluster owns the shard. Host normally. + Local = 1, + + /// A foreign cluster owns the shard; this world should not host. + Foreign = 2, +} diff --git a/src/server/Mangos.Cluster/Federation/FederatedChatDeliverer.cs b/src/server/Mangos.Cluster/Federation/FederatedChatDeliverer.cs new file mode 100644 index 00000000..7ff1a97d --- /dev/null +++ b/src/server/Mangos.Cluster/Federation/FederatedChatDeliverer.cs @@ -0,0 +1,196 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.Linq; +using Mangos.Cluster.Admin.Protocol; +using Mangos.Cluster.Globals; +using Mangos.Cluster.Handlers; +using Mangos.Common.Enums.Chat; +using Mangos.Common.Enums.Global; +using Mangos.Common.Enums.Misc; +using Mangos.Configuration; +using Mangos.Logging; + +namespace Mangos.Cluster.Federation; + +/// +/// Receives from peer clusters and delivers +/// the message to the local recipient as a standard SMSG_MESSAGECHAT. +/// +/// The sender's display tag is rendered into the visible name per +/// : whispers always carry the marker +/// (so /reply works); other channels respect the receiving account's +/// federation_show_markers preference. +/// +public sealed class FederatedChatDeliverer +{ + private readonly ClusterServiceLocator _serviceLocator; + private readonly FederationRouter _router; + private readonly FederationConfiguration _cfg; + private readonly IMangosLogger _logger; + + public FederatedChatDeliverer( + ClusterServiceLocator serviceLocator, + FederationRouter router, + MangosConfiguration mangosConfiguration, + IMangosLogger logger) + { + _serviceLocator = serviceLocator; + _router = router; + _cfg = mangosConfiguration.Federation ?? new FederationConfiguration(); + _logger = logger; + } + + public void WireUp() + { + _router.OnChat = HandleInbound; + } + + private void HandleInbound(ChatEnvelope env) + { + switch (env.Channel) + { + case ChatChannel.Whisper: + DeliverWhisper(env); + break; + case ChatChannel.Party: + case ChatChannel.Raid: + DeliverGroup(env); + break; + case ChatChannel.Guild: + case ChatChannel.GuildOfficer: + // Guilds are not yet federated (intentional - cross-realm + // guilds are a separate scope). Drop with a log. + _logger.Information($"Federation: dropped guild chat from {env.SenderName}@{env.SenderRealmId} (guilds not federated)"); + break; + case ChatChannel.System: + Broadcast(env); + break; + case ChatChannel.NamedChannel: + _logger.Information($"Federation: dropped named-channel chat from {env.SenderName}@{env.SenderRealmId} (named channels not federated)"); + break; + } + } + + private void DeliverWhisper(ChatEnvelope env) + { + if (string.IsNullOrEmpty(env.RecipientName)) return; + var target = LookupByName(env.RecipientName); + if (target?.Client is null) + { + _logger.Information($"Federation: whisper for unknown local recipient '{env.RecipientName}'"); + return; + } + DeliverTo(target, env, isWhisper: true); + } + + private void DeliverGroup(ChatEnvelope env) + { + if (env.GroupId == 0) return; + // Find local members of this group; deliver one envelope to each. + var wc = _serviceLocator.WorldCluster; + wc.CharacteRsLock.EnterReadLock(); + WcHandlerCharacter.CharacterObject[] members; + try + { + members = wc.CharacteRs.Values + .Where(c => c.IsInGroup && c.Group != null && c.Group.Id == env.GroupId) + .ToArray(); + } + finally + { + wc.CharacteRsLock.ExitReadLock(); + } + foreach (var m in members) + DeliverTo(m, env, isWhisper: false); + } + + private void Broadcast(ChatEnvelope env) + { + var wc = _serviceLocator.WorldCluster; + wc.CharacteRsLock.EnterReadLock(); + WcHandlerCharacter.CharacterObject[] all; + try + { + all = wc.CharacteRs.Values.Where(c => c.Client is not null).ToArray(); + } + finally + { + wc.CharacteRsLock.ExitReadLock(); + } + foreach (var c in all) + DeliverTo(c, env, isWhisper: false); + } + + private void DeliverTo(WcHandlerCharacter.CharacterObject target, ChatEnvelope env, bool isWhisper) + { + var senderName = RealmMarkers.Decorate( + env.SenderName, + env.SenderRealmTag, + _cfg.MarkerMode, + accountWantsMarkers: true, // per-account flag is read in the world; cluster defaults to "yes" + isWhisper: isWhisper, + placement: ParsePlacement(env)); + + // Use the synthetic guid 0 + a name-bearing channel so the WoW + // client renders the sender as "[WM] Bob" rather than as a local + // GUID (which we don't have for foreign players). + var msgType = MapChannel(env.Channel, isWhisper); + var packet = _serviceLocator.Functions.BuildChatMessage( + senderGuid: 0, + message: env.Body, + msgType: msgType, + msgLanguage: (LANGUAGES)env.Language, + flag: 0, + msgChannel: senderName); + try { target.Client?.Send(packet); } + finally { packet.Dispose(); } + } + + private static MarkerPlacement ParsePlacement(ChatEnvelope env) + // Sender-side placement is informational; receiver decides locally. + // Default to prefix until per-realm marker placement is plumbed in. + => MarkerPlacement.Prefix; + + private static ChatMsg MapChannel(ChatChannel ch, bool isWhisper) => ch switch + { + ChatChannel.Whisper => isWhisper ? ChatMsg.CHAT_MSG_WHISPER : ChatMsg.CHAT_MSG_SYSTEM, + ChatChannel.Party => ChatMsg.CHAT_MSG_PARTY, + ChatChannel.Raid => ChatMsg.CHAT_MSG_RAID, + ChatChannel.Guild => ChatMsg.CHAT_MSG_GUILD, + ChatChannel.GuildOfficer => ChatMsg.CHAT_MSG_OFFICER, + ChatChannel.System => ChatMsg.CHAT_MSG_SYSTEM, + _ => ChatMsg.CHAT_MSG_SYSTEM, + }; + + private WcHandlerCharacter.CharacterObject? LookupByName(string name) + { + var wc = _serviceLocator.WorldCluster; + wc.CharacteRsLock.EnterReadLock(); + try + { + return wc.CharacteRs.Values.FirstOrDefault(c => + _serviceLocator.CommonFunctions.UppercaseFirstLetter(c.Name) + == _serviceLocator.CommonFunctions.UppercaseFirstLetter(name)); + } + finally + { + wc.CharacteRsLock.ExitReadLock(); + } + } +} diff --git a/src/server/Mangos.Cluster/Federation/FederatedGroupInviter.cs b/src/server/Mangos.Cluster/Federation/FederatedGroupInviter.cs new file mode 100644 index 00000000..86bdf2bb --- /dev/null +++ b/src/server/Mangos.Cluster/Federation/FederatedGroupInviter.cs @@ -0,0 +1,184 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using System.Collections.Concurrent; +using System.Linq; +using Mangos.Cluster.Admin.Protocol; +using Mangos.Cluster.Globals; +using Mangos.Cluster.Handlers; +using Mangos.Common.Enums.Global; +using Mangos.Common.Globals; +using Mangos.Configuration; +using Mangos.Logging; + +namespace Mangos.Cluster.Federation; + +/// +/// Receives from peer clusters and surfaces +/// the standard SMSG_GROUP_INVITE packet to the targeted local character so +/// they see the invite popup. Replies with a GroupInviteResponseEnvelope so +/// the leader's cluster knows whether the invite was delivered. +/// +/// Outbound (this cluster's player invites a Name-RealmTag) is handled in +/// the cluster-side group invite path (WC_Handlers_Group). +/// +public sealed class FederatedGroupInviter +{ + private readonly ClusterServiceLocator _serviceLocator; + private readonly FederationRouter _router; + private readonly IMangosLogger _logger; + private readonly Func _localClusterIdProvider; + + // Pending invites awaiting accept/decline from local players. + // Keyed by recipient character guid; value carries enough context to + // emit a GroupInviteResponseEnvelope back to the leader's cluster. + private readonly ConcurrentDictionary _pending = new(); + + public FederatedGroupInviter( + ClusterServiceLocator serviceLocator, + FederationRouter router, + IMangosLogger logger, + MangosConfiguration mangosConfiguration) + { + _serviceLocator = serviceLocator; + _router = router; + _logger = logger; + var localId = mangosConfiguration.Federation?.LocalClusterId ?? 0u; + _localClusterIdProvider = () => localId; + } + + private sealed class PendingInvite + { + public required long GroupId + { + get; init; + } + public required uint LeaderRealmId + { + get; init; + } + public required string TargetName + { + get; init; + } + } + + /// Bind onto the router so inbound peer invites pop the popup locally. + public void WireUp() + { + _router.OnGroupInvite = HandleInbound; + } + + private void HandleInbound(GroupInviteEnvelope env) + { + var wc = _serviceLocator.WorldCluster; + wc.CharacteRsLock.EnterReadLock(); + WcHandlerCharacter.CharacterObject? target = null; + try + { + target = wc.CharacteRs.Values.FirstOrDefault(c => + _serviceLocator.CommonFunctions.UppercaseFirstLetter(c.Name) + == _serviceLocator.CommonFunctions.UppercaseFirstLetter(env.TargetName)); + } + finally + { + wc.CharacteRsLock.ExitReadLock(); + } + + if (target is null || target.Client is null) + { + // Not online here; tell the leader's cluster. + ReplyAsync(env, GroupInviteResponse.NotFound, 0, env.TargetName); + return; + } + + // Render leader's name with their realm tag prepended so the invitee + // sees [WM] Bob in the popup. Whisper-style, always prefixed. + var leaderRendered = string.IsNullOrEmpty(env.LeaderRealmTag) + ? env.LeaderName + : $"[{env.LeaderRealmTag}] {env.LeaderName}"; + + try + { + PacketClass invite = new(Opcodes.SMSG_GROUP_INVITE); + invite.AddInt8(1); + invite.AddString(leaderRendered); + target.Client.Send(invite); + invite.Dispose(); + _logger.Information($"Federation: delivered group invite from {env.LeaderName}@{env.LeaderRealmId} to {env.TargetName}"); + // Stash so On_CMSG_GROUP_ACCEPT/DECLINE can fire the response. + _pending[target.Guid] = new PendingInvite + { + GroupId = env.GroupId, + LeaderRealmId = env.LeaderRealmId, + TargetName = target.Name, + }; + } + catch + { + ReplyAsync(env, GroupInviteResponse.NotFound, 0, env.TargetName); + } + } + + /// + /// Called from the cluster's CMSG_GROUP_ACCEPT handler when the local + /// recipient clicks Accept. Returns true if the invite was federated + /// (and the response has been queued); false if it was a local invite + /// and should fall through to the standard path. + /// + public bool TryHandleAccept(ulong recipientGuid) + { + if (!_pending.TryRemove(recipientGuid, out var p)) return false; + ReplyAsync(p.GroupId, p.LeaderRealmId, GroupInviteResponse.Accepted, recipientGuid, p.TargetName); + return true; + } + + /// Counterpart to TryHandleAccept for declines. + public bool TryHandleDecline(ulong recipientGuid) + { + if (!_pending.TryRemove(recipientGuid, out var p)) return false; + ReplyAsync(p.GroupId, p.LeaderRealmId, GroupInviteResponse.Declined, recipientGuid, p.TargetName); + return true; + } + + private void ReplyAsync(GroupInviteEnvelope env, GroupInviteResponse decision, ulong targetGuid, string targetName) + => ReplyAsync(env.GroupId, env.LeaderRealmId, decision, targetGuid, targetName); + + private async void ReplyAsync(long groupId, uint leaderRealmId, GroupInviteResponse decision, ulong targetGuid, string targetName) + { + try + { + var link = await _router.GetOrOpenAsync(leaderRealmId); + if (link is null) return; + await link.SendGroupInviteResponseAsync(new GroupInviteResponseEnvelope + { + GroupId = groupId, + TargetRealmId = _localClusterIdProvider(), + TargetGuid = targetGuid, + TargetName = targetName, + Decision = decision, + }); + } + catch + { + // Best effort. + } + } + +} diff --git a/src/server/Mangos.Cluster/Federation/FederatedShardClaimer.cs b/src/server/Mangos.Cluster/Federation/FederatedShardClaimer.cs new file mode 100644 index 00000000..1baa1900 --- /dev/null +++ b/src/server/Mangos.Cluster/Federation/FederatedShardClaimer.cs @@ -0,0 +1,116 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.Linq; +using System.Threading.Tasks; +using Mangos.Cluster.Admin.Protocol; +using Mangos.Cluster.Globals; +using Mangos.Cluster.Handlers; +using Mangos.Configuration; +using Mangos.Logging; + +namespace Mangos.Cluster.Federation; + +/// +/// Leader-cluster side of Phase B shard claims. When a peer cluster's +/// player accepts a federated invite (we receive a +/// with Decision=Accepted), +/// we emit a back to that peer for the +/// leader's current map. The peer's records +/// the claim so its world's enter-zone hook refuses to host that map +/// for any character in the same group, and the player is told to +/// reconnect to our realm. +/// +public sealed class FederatedShardClaimer +{ + private readonly ClusterServiceLocator _serviceLocator; + private readonly FederationRouter _router; + private readonly IMangosLogger _logger; + private readonly FederationConfiguration _cfg; + + public FederatedShardClaimer( + ClusterServiceLocator serviceLocator, + FederationRouter router, + MangosConfiguration mangosConfiguration, + IMangosLogger logger) + { + _serviceLocator = serviceLocator; + _router = router; + _logger = logger; + _cfg = mangosConfiguration.Federation ?? new FederationConfiguration(); + } + + public void WireUp() + { + _router.OnGroupInviteResponse = HandleResponse; + } + + private async void HandleResponse(GroupInviteResponseEnvelope env) + { + if (env.Decision != GroupInviteResponse.Accepted) return; + + // Find the leader's character locally by groupId. + var wc = _serviceLocator.WorldCluster; + wc.CharacteRsLock.EnterReadLock(); + WcHandlerCharacter.CharacterObject? leader = null; + try + { + leader = wc.CharacteRs.Values.FirstOrDefault(c => + c.IsInGroup && c.Group != null && c.Group.Id == env.GroupId + && c.Group.GetLeader() == c); + } + finally + { + wc.CharacteRsLock.ExitReadLock(); + } + + if (leader is null) + { + _logger.Warning($"Federation: invite-accepted but local leader for group {env.GroupId} not found"); + return; + } + + var claim = new ShardClaimEnvelope + { + GroupId = env.GroupId, + OwnerClusterId = _cfg.LocalClusterId, + MapId = leader.Map, + ShardKey = (ulong)env.GroupId, + // RelayEndpoint is the host cluster's federation listener; the + // peer uses this to know "where to point the player's client at". + RelayEndpoint = $"{_cfg.ListenAddress}:{_cfg.ListenPort}", + }; + + try + { + var link = await _router.GetOrOpenAsync(env.TargetRealmId); + if (link is null) + { + _logger.Warning($"Federation: cannot send shard claim - peer {env.TargetRealmId} unreachable"); + return; + } + await link.SendShardClaimAsync(claim); + _logger.Information($"Federation: shard claim emitted for group {env.GroupId} on map {leader.Map} -> peer {env.TargetRealmId}"); + } + catch + { + // Best-effort; the peer's player will fall through to the standard + // "no group" experience until the next attempt. + } + } +} diff --git a/src/server/Mangos.Cluster/Federation/FederationRouter.cs b/src/server/Mangos.Cluster/Federation/FederationRouter.cs new file mode 100644 index 00000000..5b8b3742 --- /dev/null +++ b/src/server/Mangos.Cluster/Federation/FederationRouter.cs @@ -0,0 +1,298 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Mangos.Cluster.Admin.Auth; +using Mangos.Cluster.Admin.Commands; +using Mangos.Cluster.Admin.Protocol; +using Mangos.Configuration; +using Mangos.Logging; +using Mangos.MySql.GetFederationPeers; + +namespace Mangos.Cluster.Federation; + +/// +/// Cluster-side outbound side of the federation transport: maintains +/// dial-out connections to peer clusters by realm id, multiplexes admin +/// / chat / group / presence envelopes over each, and lazily reconnects +/// after drops. +/// +/// Peer endpoints come from the realmlist DB column added in PR #4 +/// (clusterAdminEndpoint); the per-peer secret comes from the +/// FederationConfiguration.Peers list. Both lookups are injected so this +/// class doesn't grow MySql dependencies. +/// +public sealed class FederationRouter : IDisposable +{ + private readonly FederationConfiguration _cfg; + private readonly IMangosLogger _logger; + private readonly Func _resolveEndpoint; + private readonly ConcurrentDictionary _outbound = new(); + private readonly ConcurrentDictionary _peerInfo = new(); + private readonly IGetFederationPeersQuery? _peersQuery; + private CancellationTokenSource? _refreshCts; + private Task? _refreshLoop; + + public FederationRouter( + FederationConfiguration cfg, + IMangosLogger logger, + Func resolveEndpoint, + IGetFederationPeersQuery? peersQuery = null) + { + _cfg = cfg; + _logger = logger; + _resolveEndpoint = resolveEndpoint; + _peersQuery = peersQuery; + } + + /// Information about a known peer cluster, populated from the realmlist DB. + public sealed class FederationPeerInfo + { + public required uint ClusterId + { + get; init; + } + public required string Endpoint + { + get; init; + } + public required string DisplayTag + { + get; init; + } + public required string MarkerPosition + { + get; init; + } + } + + /// Snapshot of all peers discovered from the realmlist table. + public IReadOnlyDictionary PeerInfo => _peerInfo; + + /// Active outbound links keyed by remote cluster id. + public IReadOnlyDictionary Peers => _outbound; + + /// Optional callbacks on inbound envelopes; bound by gameplay code. + public Action? OnChat + { + get; set; + } + public Action? OnGroupInvite + { + get; set; + } + public Action? OnGroupInviteResponse + { + get; set; + } + public Action? OnGroupRosterUpdate + { + get; set; + } + public Func? OnPresenceQuery + { + get; set; + } + public Action? OnShardClaim + { + get; set; + } + public Action? OnShardRelease + { + get; set; + } + + /// Bind the outbound side's hooks onto a newly opened or accepted link. + public void BindHandlers(FederationLink link) + { + link.OnChatRoute = e => OnChat?.Invoke(e); + link.OnGroupInvite = e => OnGroupInvite?.Invoke(e); + link.OnGroupInviteResponse = e => OnGroupInviteResponse?.Invoke(e); + link.OnGroupRosterUpdate = e => OnGroupRosterUpdate?.Invoke(e); + link.OnPresenceQuery = e => OnPresenceQuery?.Invoke(e) ?? new PresenceReplyEnvelope { Name = e.Name, Online = false }; + link.OnShardClaim = e => OnShardClaim?.Invoke(e); + link.OnShardRelease = e => OnShardRelease?.Invoke(e); + } + + /// + /// Start the periodic peer-table refresh from the realmlist DB plus the + /// auto-dial / heartbeat maintenance loop. Safe to call once at cluster + /// startup. The refresh side is a no-op when no DB query is bound. + /// + public Task StartAsync() + { + _refreshCts = new CancellationTokenSource(); + if (_peersQuery is not null) + _refreshLoop = Task.Run(() => RefreshLoopAsync(_refreshCts.Token)); + _ = Task.Run(() => MaintainLinksAsync(_refreshCts.Token)); + return Task.CompletedTask; + } + + private async Task MaintainLinksAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + // For each known peer, ensure we have an outbound link and + // heartbeat the live ones. Failures here drop the link so the + // next iteration redials. + foreach (var info in _peerInfo.Values) + { + if (ct.IsCancellationRequested) return; + if (info.ClusterId == _cfg.LocalClusterId) continue; + try + { + var link = await GetOrOpenAsync(info.ClusterId); + if (link is null) continue; + await link.HeartbeatAsync().WaitAsync(TimeSpan.FromSeconds(10), ct); + } + catch + { + if (_outbound.TryRemove(info.ClusterId, out var bad)) + { + try { bad.Dispose(); } catch { } + _logger.Warning($"Federation: heartbeat to peer {info.ClusterId} failed; link dropped"); + } + } + } + try { await Task.Delay(TimeSpan.FromSeconds(15), ct); } + catch (OperationCanceledException) { return; } + } + } + + private async Task RefreshLoopAsync(CancellationToken ct) + { + // First load is eager so the first GetOrOpenAsync after startup + // sees populated peer info. + await RefreshPeersOnceAsync(); + while (!ct.IsCancellationRequested) + { + try { await Task.Delay(TimeSpan.FromMinutes(1), ct); } + catch (OperationCanceledException) { return; } + await RefreshPeersOnceAsync(); + } + } + + private async Task RefreshPeersOnceAsync() + { + try + { + var rows = await _peersQuery!.ExecuteAsync(); + if (rows is null) return; + var fresh = new Dictionary(); + foreach (var r in rows) + { + if (r.clusterId == 0 || string.IsNullOrEmpty(r.clusterAdminEndpoint)) continue; + fresh[r.clusterId] = new FederationPeerInfo + { + ClusterId = r.clusterId, + Endpoint = r.clusterAdminEndpoint, + DisplayTag = r.displayTag ?? string.Empty, + MarkerPosition = r.markerPosition ?? "prefix", + }; + } + // Replace wholesale. + foreach (var k in _peerInfo.Keys.Where(k => !fresh.ContainsKey(k)).ToList()) + _peerInfo.TryRemove(k, out _); + foreach (var kv in fresh) + _peerInfo[kv.Key] = kv.Value; + } + catch (Exception ex) + { + _logger.Warning($"Federation: realmlist peer refresh failed: {ex.Message}"); + } + } + + /// + /// Get or open a federation link to the cluster that owns realm id N. + /// Returns null if the peer is unreachable or no secret is configured. + /// Endpoint is resolved (in order) from: cached realmlist row, then the + /// caller-supplied resolveEndpoint lambda for tests/overrides. + /// + public async Task GetOrOpenAsync(uint realmId) + { + if (_outbound.TryGetValue(realmId, out var existing) && existing.IsAuthenticated) + return existing; + + string? endpoint = null; + if (_peerInfo.TryGetValue(realmId, out var info)) + endpoint = info.Endpoint; + endpoint ??= _resolveEndpoint(realmId); + if (string.IsNullOrEmpty(endpoint)) + return null; + + // Find the peer secret. We key peers by *peer's* clusterId, not realmId, + // but for a single-realm-per-cluster setup these are equivalent. The + // realmlist row carries clusterId so callers should pass that here. + byte[]? secret = null; + foreach (var p in _cfg.Peers) + { + if (p.ClusterId == realmId) + { + secret = PeerAuth.SecretFromString(p.Secret); + break; + } + } + if (secret is null) + { + _logger.Warning($"Federation: no secret configured for cluster id {realmId}; refusing dial"); + return null; + } + + var parts = endpoint.Split(':'); + if (parts.Length != 2 || !int.TryParse(parts[1], out var port)) + { + _logger.Warning($"Federation: bad endpoint '{endpoint}' for cluster id {realmId}"); + return null; + } + + try + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + await socket.ConnectAsync(new IPEndPoint(IPAddress.Parse(parts[0]), port)); + var link = new FederationLink(socket); + BindHandlers(link); + await link.ConnectAsAsync(_cfg.LocalClusterId, _cfg.LocalDisplayTag, secret); + _outbound[realmId] = link; + link.Disconnected += () => _outbound.TryRemove(realmId, out _); + _logger.Information($"Federation: dialed peer cluster {realmId} at {endpoint}"); + return link; + } + catch (Exception ex) + { + _logger.Warning($"Federation: dial to cluster {realmId} ({endpoint}) failed: {ex.Message}"); + return null; + } + } + + public void Dispose() + { + _refreshCts?.Cancel(); + try { _refreshLoop?.Wait(TimeSpan.FromSeconds(2)); } catch { } + _refreshCts?.Dispose(); + foreach (var l in _outbound.Values) + try { l.Dispose(); } catch { } + _outbound.Clear(); + } +} diff --git a/src/server/Mangos.Cluster/Federation/RealmMarkers.cs b/src/server/Mangos.Cluster/Federation/RealmMarkers.cs new file mode 100644 index 00000000..10a5eadb --- /dev/null +++ b/src/server/Mangos.Cluster/Federation/RealmMarkers.cs @@ -0,0 +1,66 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using Mangos.Configuration; + +namespace Mangos.Cluster.Federation; + +/// +/// Centralised renderer for the cross-realm display tag. Applied at the +/// receiving cluster only - we never trust a tag that came in over the +/// wire (peers could spoof). The receiver looks up the sender realm's +/// configured tag locally and decides whether to render based on +/// FederationMarkerMode + the per-account opt-in. +/// +public static class RealmMarkers +{ + /// Decorate with the supplied tag per the marker mode. + public static string Decorate( + string name, + string tag, + FederationMarkerMode mode, + bool accountWantsMarkers, + bool isWhisper, + MarkerPlacement placement = MarkerPlacement.Prefix) + { + if (string.IsNullOrEmpty(tag)) return name; + + bool render = mode switch + { + FederationMarkerMode.Off => isWhisper, // Whispers always carry the marker for replyability. + FederationMarkerMode.Always => true, + FederationMarkerMode.ClientPreference => accountWantsMarkers || isWhisper, + _ => false, + }; + if (!render) return name; + + return placement switch + { + MarkerPlacement.Prefix => $"[{tag}] {name}", + MarkerPlacement.Suffix => $"{name} [{tag}]", + _ => name, + }; + } +} + +public enum MarkerPlacement +{ + Prefix = 0, + Suffix = 1, + None = 2, +} diff --git a/src/server/Mangos.Cluster/Federation/ShardRegistry.cs b/src/server/Mangos.Cluster/Federation/ShardRegistry.cs new file mode 100644 index 00000000..e1a22f00 --- /dev/null +++ b/src/server/Mangos.Cluster/Federation/ShardRegistry.cs @@ -0,0 +1,112 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.Collections.Concurrent; +using System.Collections.Generic; +using Mangos.Cluster.Admin.Protocol; +using Mangos.Logging; + +namespace Mangos.Cluster.Federation; + +/// +/// Phase B foundation: in-memory registry of active shards. Each entry +/// records "for this (mapId, shardKey) the host cluster is N at relay +/// endpoint E". When a player from a federated group enters mapId, the +/// world looks up its shardKey here and decides whether to host locally +/// or proxy to the recorded relay endpoint. +/// +/// The actual world-packet proxying is intentionally not built yet - +/// this registry is the first half. Once the world's enter-zone path +/// consults we can add the proxy fan-out. +/// +public sealed class ShardRegistry +{ + private readonly IMangosLogger _logger; + // Key is (mapId, shardKey). + private readonly ConcurrentDictionary<(uint MapId, ulong ShardKey), ShardEntry> _shards = new(); + + public ShardRegistry(IMangosLogger logger) + { + _logger = logger; + } + + public sealed class ShardEntry + { + public required long GroupId + { + get; init; + } + public required uint OwnerClusterId + { + get; init; + } + public required uint MapId + { + get; init; + } + public required ulong ShardKey + { + get; init; + } + public required string RelayEndpoint + { + get; init; + } + } + + /// Look up the shard for the given (mapId, shardKey). Returns null if unknown. + public ShardEntry? GetShard(uint mapId, ulong shardKey) + => _shards.TryGetValue((mapId, shardKey), out var s) ? s : null; + + /// All currently-known shards, for diagnostics and admin reporting. + public IEnumerable All() => _shards.Values; + + /// Bind onto a router so inbound shard claim/release envelopes update the registry. + public void WireUp(FederationRouter router) + { + router.OnShardClaim = e => Apply(e); + router.OnShardRelease = e => Release(e.ShardKey, e.GroupId); + } + + private void Apply(ShardClaimEnvelope e) + { + var entry = new ShardEntry + { + GroupId = e.GroupId, + OwnerClusterId = e.OwnerClusterId, + MapId = e.MapId, + ShardKey = e.ShardKey, + RelayEndpoint = e.RelayEndpoint, + }; + _shards[(e.MapId, e.ShardKey)] = entry; + _logger.Information($"Shard claim: group {e.GroupId} -> map {e.MapId} key {e.ShardKey} via {e.RelayEndpoint}"); + } + + private void Release(ulong shardKey, long groupId) + { + // ShardReleaseEnvelope doesn't carry mapId today; sweep by key+group. + foreach (var kv in _shards) + { + if (kv.Key.ShardKey == shardKey && kv.Value.GroupId == groupId) + { + _shards.TryRemove(kv.Key, out _); + _logger.Information($"Shard release: group {groupId} key {shardKey}"); + } + } + } +} diff --git a/src/server/Mangos.Cluster/Handlers/WC_Handlers_Chat.cs b/src/server/Mangos.Cluster/Handlers/WC_Handlers_Chat.cs index 31edf7ee..d902c0a5 100644 --- a/src/server/Mangos.Cluster/Handlers/WC_Handlers_Chat.cs +++ b/src/server/Mangos.Cluster/Handlers/WC_Handlers_Chat.cs @@ -16,6 +16,8 @@ // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA // +using Mangos.Cluster.Admin.Protocol; +using Mangos.Cluster.Federation; using Mangos.Cluster.Globals; using Mangos.Cluster.Network; using Mangos.Common.Enums.Chat; @@ -28,10 +30,74 @@ namespace Mangos.Cluster.Handlers; public class WcHandlersChat { private readonly ClusterServiceLocator _clusterServiceLocator; + private readonly FederationRouter? _federation; - public WcHandlersChat(ClusterServiceLocator clusterServiceLocator) + public WcHandlersChat(ClusterServiceLocator clusterServiceLocator, FederationRouter? federation = null) { _clusterServiceLocator = clusterServiceLocator; + _federation = federation; + } + + /// + /// Try to forward a "Name-RealmTag" whisper to a peer cluster. Returns + /// true if we identified a routable target (regardless of delivery + /// success), false if the target name doesn't carry a realm suffix. + /// + /// "RealmTag" can be either a numeric clusterId or the textual tag + /// stored in realmlist.displayTag (case-insensitive); the router's + /// peer cache provides the lookup. + /// + private bool TryRouteFederatedWhisper(ClientClass client, string toName, string message, LANGUAGES language) + { + if (_federation is null) return false; + var dash = toName.LastIndexOf('-'); + if (dash <= 0 || dash == toName.Length - 1) return false; + var bareName = toName.Substring(0, dash); + var realmTag = toName.Substring(dash + 1); + + uint peerClusterId = 0; + if (uint.TryParse(realmTag, out var asInt)) + { + peerClusterId = asInt; + } + else + { + foreach (var info in _federation.PeerInfo.Values) + { + if (string.Equals(info.DisplayTag, realmTag, System.StringComparison.OrdinalIgnoreCase)) + { + peerClusterId = info.ClusterId; + break; + } + } + } + if (peerClusterId == 0) return false; + + try + { + var env = new ChatEnvelope + { + SenderRealmId = 0, + SenderRealmTag = string.Empty, + SenderGuid = client.Character.Guid, + SenderName = client.Character.Name, + Channel = ChatChannel.Whisper, + RecipientName = bareName, + Language = (uint)language, + Body = message, + }; + _ = _federation.GetOrOpenAsync(peerClusterId) + .ContinueWith(async t => + { + var link = t.Result; + if (link is not null) await link.SendChatAsync(env); + }); + } + catch + { + // Best effort. + } + return true; } public void On_CMSG_CHAT_IGNORED(PacketClass packet, ClientClass client) @@ -139,6 +205,10 @@ public void On_CMSG_MESSAGECHAT(PacketClass packet, ClientClass client) } } } + else if (TryRouteFederatedWhisper(client, toUser, message, msgLanguage)) + { + // Forwarded to peer cluster; nothing else to do locally. + } else { PacketClass smsgChatPlayerNotFound = new(Opcodes.SMSG_CHAT_PLAYER_NOT_FOUND); diff --git a/src/server/Mangos.Cluster/Handlers/WC_Handlers_Group.cs b/src/server/Mangos.Cluster/Handlers/WC_Handlers_Group.cs index 370a090a..a212a930 100644 --- a/src/server/Mangos.Cluster/Handlers/WC_Handlers_Group.cs +++ b/src/server/Mangos.Cluster/Handlers/WC_Handlers_Group.cs @@ -20,6 +20,8 @@ using System.Collections.Generic; using System.Data; using System.Threading; +using Mangos.Cluster.Admin.Protocol; +using Mangos.Cluster.Federation; using Mangos.Cluster.Globals; using Mangos.Cluster.Network; using Mangos.Common.Enums.Chat; @@ -35,10 +37,75 @@ namespace Mangos.Cluster.Handlers; public class WcHandlersGroup { private readonly ClusterServiceLocator _clusterServiceLocator; + private readonly FederationRouter? _federation; + private readonly FederatedGroupInviter? _inviter; - public WcHandlersGroup(ClusterServiceLocator clusterServiceLocator) + public WcHandlersGroup( + ClusterServiceLocator clusterServiceLocator, + FederationRouter? federation = null, + FederatedGroupInviter? inviter = null) { _clusterServiceLocator = clusterServiceLocator; + _federation = federation; + _inviter = inviter; + } + + /// + /// Try to forward a "Name-RealmTag" group invite to a peer cluster. + /// Returns true if we shipped (or attempted to ship) the envelope, false + /// if the invitee name carries no realm suffix. + /// + private bool TryRouteFederatedInvite(ClientClass client, string toName) + { + if (_federation is null) return false; + var dash = toName.LastIndexOf('-'); + if (dash <= 0 || dash == toName.Length - 1) return false; + var bareName = toName.Substring(0, dash); + var realmTag = toName.Substring(dash + 1); + + uint peerClusterId = 0; + if (uint.TryParse(realmTag, out var asInt)) + { + peerClusterId = asInt; + } + else + { + foreach (var info in _federation.PeerInfo.Values) + { + if (string.Equals(info.DisplayTag, realmTag, StringComparison.OrdinalIgnoreCase)) + { + peerClusterId = info.ClusterId; + break; + } + } + } + if (peerClusterId == 0) return false; + + try + { + var groupId = client.Character.IsInGroup ? client.Character.Group.Id : 0; + var env = new GroupInviteEnvelope + { + GroupId = groupId, + LeaderRealmId = 0, // peer infers from the link's RemoteClusterId + LeaderGuid = client.Character.Guid, + LeaderName = client.Character.Name, + LeaderRealmTag = string.Empty, + TargetName = bareName, + GroupType = 0, + }; + _ = _federation.GetOrOpenAsync(peerClusterId) + .ContinueWith(async t => + { + var link = t.Result; + if (link is not null) await link.SendGroupInviteAsync(env); + }); + } + catch + { + // Best-effort. + } + return true; } // Used as counter for unique Group.ID @@ -523,6 +590,12 @@ public void On_CMSG_GROUP_INVITE(PacketClass packet, ClientClass client) // TODO: InBattlegrounds: INVITE_RESTRICTED if (guid == 0m) { + // Local miss: try cross-realm before failing. + if (TryRouteFederatedInvite(client, name)) + { + SendPartyResult(client, name, PartyCommand.PARTY_OP_INVITE, PartyCommandResult.INVITE_OK); + return; + } errCode = PartyCommandResult.INVITE_NOT_FOUND; } else if (_clusterServiceLocator.WorldCluster.CharacteRs[guid].IsInWorld == false) @@ -586,6 +659,15 @@ public void On_CMSG_GROUP_CANCEL(PacketClass packet, ClientClass client) public void On_CMSG_GROUP_ACCEPT(PacketClass packet, ClientClass client) { _clusterServiceLocator.WorldCluster.Log.WriteLine(LogType.DEBUG, "[{0}:{1}] CMSG_GROUP_ACCEPT", client.IP, client.Port); + + // Federated invite? Reply across the bus and stop; the local + // group object isn't valid because the leader lives elsewhere. + if (_inviter is not null && client.Character is not null + && _inviter.TryHandleAccept(client.Character.Guid)) + { + return; + } + if (client.Character.GroupInvitedFlag && !client.Character.Group.IsFull) { client.Character.Group.Join(client.Character); @@ -602,6 +684,14 @@ public void On_CMSG_GROUP_ACCEPT(PacketClass packet, ClientClass client) public void On_CMSG_GROUP_DECLINE(PacketClass packet, ClientClass client) { _clusterServiceLocator.WorldCluster.Log.WriteLine(LogType.DEBUG, "[{0}:{1}] CMSG_GROUP_DECLINE", client.IP, client.Port); + + // Federated invite? Reply across the bus and stop. + if (_inviter is not null && client.Character is not null + && _inviter.TryHandleDecline(client.Character.Guid)) + { + return; + } + if (client.Character.GroupInvitedFlag) { PacketClass response = new(Opcodes.SMSG_GROUP_DECLINE); diff --git a/src/server/Mangos.Cluster/LegacyClusterModule.cs b/src/server/Mangos.Cluster/LegacyClusterModule.cs index 4439f0a9..59e0b84f 100644 --- a/src/server/Mangos.Cluster/LegacyClusterModule.cs +++ b/src/server/Mangos.Cluster/LegacyClusterModule.cs @@ -17,16 +17,22 @@ // using Autofac; +using Mangos.Cluster.Admin.Commands; using Mangos.Cluster.DataStores; +using Mangos.Cluster.Federation; using Mangos.Cluster.Globals; using Mangos.Cluster.Handlers; using Mangos.Cluster.Handlers.Guild; using Mangos.Cluster.Interop; using Mangos.Cluster.Network; +using Mangos.Cluster.Supervision; using Mangos.Cluster.Verification; using Mangos.Common; using Mangos.Common.Globals; +using Mangos.Configuration; using Mangos.DataStores; +using Mangos.Logging; +using Mangos.MySql.GetFederationPeers; using Mangos.Zip; namespace Mangos.Cluster; @@ -44,7 +50,14 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().As().SingleInstance(); + builder.Register(ctx => new WorldServerClass( + ctx.Resolve(), + ctx.ResolveOptional(), + ctx.ResolveOptional(), + ctx.ResolveOptional(), + ctx.ResolveOptional(), + () => ctx.Resolve().Federation?.LocalClusterId ?? 0u)) + .As().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); @@ -69,5 +82,47 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As() .PropertiesAutowired(PropertyWiringOptions.AllowCircularDependencies) .SingleInstance(); + + builder.Register(ctx => + { + var cfg = ctx.Resolve(); + var logger = ctx.Resolve(); + return new WorldSupervisor(cfg.Supervisor ?? new SupervisorConfiguration(), logger); + }).As().SingleInstance(); + + builder.Register(ctx => + { + var supervisor = ctx.Resolve(); + var cfg = ctx.Resolve(); + var federation = ctx.ResolveOptional(); + var shards = ctx.ResolveOptional(); + return new ClusterAdminCommandHandler( + supervisor, + () => cfg.Federation?.LocalClusterId ?? 0u, + federation, + shards); + }).As().SingleInstance(); + + // Federation router. Created even when federation is disabled so + // gameplay code can resolve it unconditionally. Endpoint resolution + // pulls from the realmlist DB (clusterAdminEndpoint column) on a + // 60-second refresh; the lambda is a fallback for tests and for + // pre-DB-load lookups. + builder.Register(ctx => + { + var cfg = ctx.Resolve(); + var logger = ctx.Resolve(); + var peersQuery = ctx.ResolveOptional(); + return new FederationRouter( + cfg.Federation ?? new FederationConfiguration(), + logger, + _ => null, + peersQuery); + }).As().SingleInstance(); + + builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); } } diff --git a/src/server/Mangos.Cluster/Mangos.Cluster.csproj b/src/server/Mangos.Cluster/Mangos.Cluster.csproj index 3eee1451..2012a265 100644 --- a/src/server/Mangos.Cluster/Mangos.Cluster.csproj +++ b/src/server/Mangos.Cluster/Mangos.Cluster.csproj @@ -10,6 +10,7 @@ + diff --git a/src/server/Mangos.Cluster/Network/WorldServerClass.cs b/src/server/Mangos.Cluster/Network/WorldServerClass.cs index 0101b84b..dbcb3fdd 100644 --- a/src/server/Mangos.Cluster/Network/WorldServerClass.cs +++ b/src/server/Mangos.Cluster/Network/WorldServerClass.cs @@ -20,8 +20,13 @@ using System.Collections; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; +using Mangos.Cluster.Admin.Commands; +using Mangos.Cluster.Admin.Protocol; +using Mangos.Cluster.Federation; using Mangos.Cluster.Globals; using Mangos.Cluster.Interop; +using Mangos.Cluster.Supervision; using Mangos.Common.Enums.Chat; using Mangos.Common.Enums.Global; using Mangos.Common.Globals; @@ -31,13 +36,29 @@ namespace Mangos.Cluster.Network; public class WorldServerClass : ICluster { private readonly ClusterServiceLocator _clusterServiceLocator; + private readonly WorldSupervisor? _supervisor; + private readonly IAdminCommandHandler? _adminHandler; + private readonly FederationRouter? _federation; + private readonly ShardRegistry? _shards; + private readonly Func? _localClusterIdProvider; public bool MFlagStopListen; private Timer _mTimerPing; - public WorldServerClass(ClusterServiceLocator clusterServiceLocator) + public WorldServerClass( + ClusterServiceLocator clusterServiceLocator, + WorldSupervisor? supervisor = null, + IAdminCommandHandler? adminHandler = null, + FederationRouter? federation = null, + ShardRegistry? shards = null, + Func? localClusterIdProvider = null) { _clusterServiceLocator = clusterServiceLocator; + _supervisor = supervisor; + _adminHandler = adminHandler; + _federation = federation; + _shards = shards; + _localClusterIdProvider = localClusterIdProvider; } public void Start() @@ -65,6 +86,8 @@ public bool Connect(string uri, List maps, IWorld world) WorldsInfo[map] = worldServerInfo; } } + // Inform supervisor: the world identified by 'uri' is now running. + _supervisor?.OnWorldHello(uri, world, maps); } catch (Exception ex) { @@ -122,6 +145,7 @@ public void Disconnect(string uri, List maps) } } } + _supervisor?.OnWorldGoodbye(uri); } public void Ping(object state) @@ -423,6 +447,130 @@ public void BattlefieldFinish(int battlefieldId) _clusterServiceLocator.WorldCluster.Log.WriteLine(LogType.INFORMATION, "[B{0:0000}] Battlefield finished", battlefieldId); } + public byte[] RunAdminCommand(byte[] commandBytes) + { + if (_adminHandler is null) + { + return new AdminCommandReply + { + Status = AdminReplyStatus.Failed, + Lines = { "admin handler not available on this cluster" }, + }.Serialize(); + } + try + { + var cmd = AdminCommand.Deserialize(commandBytes); + + // Cross-realm: dial the peer and forward the command. + if (cmd.TargetRealmId != 0 && _federation is not null) + { + var link = _federation.GetOrOpenAsync(cmd.TargetRealmId).GetAwaiter().GetResult(); + if (link is null) + { + return new AdminCommandReply + { + Status = AdminReplyStatus.Unreachable, + Lines = { $"realm {cmd.TargetRealmId} unreachable" }, + }.Serialize(); + } + var peerReply = link.SendAdminCommandAsync(cmd).GetAwaiter().GetResult(); + return peerReply.Serialize(); + } + + var reply = _adminHandler.ExecuteAsync(cmd).GetAwaiter().GetResult(); + return reply.Serialize(); + } + catch (Exception ex) + { + return new AdminCommandReply + { + Status = AdminReplyStatus.Failed, + Lines = { $"admin error: {ex.Message}" }, + }.Serialize(); + } + } + + public void RouteFederatedChat(uint targetRealmId, byte[] chatEnvelope) + { + if (_federation is null) return; + _ = Task.Run(async () => + { + try + { + var link = await _federation.GetOrOpenAsync(targetRealmId); + if (link is null) return; + await link.SendChatAsync(ChatEnvelope.Deserialize(chatEnvelope)); + } + catch + { + // Best-effort fire-and-forget; chat losses are tolerable. + } + }); + } + + public void RouteFederatedGroupInvite(uint targetRealmId, byte[] inviteEnvelope) + { + if (_federation is null) return; + _ = Task.Run(async () => + { + try + { + var link = await _federation.GetOrOpenAsync(targetRealmId); + if (link is null) return; + await link.SendGroupInviteAsync(GroupInviteEnvelope.Deserialize(inviteEnvelope)); + } + catch + { + // Best-effort. + } + }); + } + + public ShardLookupResult QueryShard(uint mapId, ulong characterGuid) + { + // Phase B: a (mapId, shardKey) tuple identifies a shard. The shard + // key for a group-shard is the groupId, so we resolve characterGuid + // -> group -> shardKey, then look up the shard registry. + if (_shards is null) return ShardLookupResult.NoShard; + + long shardKey = 0; + var wc = _clusterServiceLocator.WorldCluster; + wc.CharacteRsLock.EnterReadLock(); + try + { + if (wc.CharacteRs.TryGetValue(characterGuid, out var character) + && character.IsInGroup + && character.Group is not null) + { + shardKey = character.Group.Id; + } + } + finally + { + wc.CharacteRsLock.ExitReadLock(); + } + if (shardKey == 0) return ShardLookupResult.NoShard; + + var entry = _shards.GetShard(mapId, (ulong)shardKey); + if (entry is null) return ShardLookupResult.NoShard; + + var localId = _localClusterIdProvider?.Invoke() ?? 0u; + if (entry.OwnerClusterId == localId) return ShardLookupResult.Local; + + // Foreign: enrich with the owner's tag/endpoint from the federation + // peer cache so the world can show a meaningful message. + string tag = string.Empty; + if (_federation is not null && _federation.PeerInfo.TryGetValue(entry.OwnerClusterId, out var peer)) + tag = peer.DisplayTag; + return new ShardLookupResult + { + Kind = ShardLookupKind.Foreign, + OwnerClusterId = entry.OwnerClusterId, + OwnerEndpoint = entry.RelayEndpoint, + OwnerDisplayTag = tag, + }; + } + public void GroupRequestUpdate(uint id) { if (_clusterServiceLocator.WorldCluster.ClienTs.ContainsKey(id) && _clusterServiceLocator.WorldCluster.ClienTs[id].Character is not null && _clusterServiceLocator.WorldCluster.ClienTs[id].Character.IsInWorld && _clusterServiceLocator.WorldCluster.ClienTs[id].Character.IsInGroup) diff --git a/src/server/Mangos.Cluster/Supervision/ClusterAdminCommandHandler.cs b/src/server/Mangos.Cluster/Supervision/ClusterAdminCommandHandler.cs new file mode 100644 index 00000000..308bde93 --- /dev/null +++ b/src/server/Mangos.Cluster/Supervision/ClusterAdminCommandHandler.cs @@ -0,0 +1,324 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Mangos.Cluster.Admin.Commands; +using Mangos.Cluster.Federation; + +namespace Mangos.Cluster.Supervision; + +/// +/// Local executor for admin commands. Reads/writes the +/// state and produces a human-readable reply +/// for the operator. Cross-realm routing (TargetRealmId != local) is +/// done by the federation router before this handler sees the command. +/// +public sealed class ClusterAdminCommandHandler : IAdminCommandHandler +{ + private readonly WorldSupervisor _supervisor; + private readonly Func _localRealmIdProvider; + private readonly FederationRouter? _federation; + private readonly ShardRegistry? _shards; + + public ClusterAdminCommandHandler( + WorldSupervisor supervisor, + Func localRealmIdProvider, + FederationRouter? federation = null, + ShardRegistry? shards = null) + { + _supervisor = supervisor; + _localRealmIdProvider = localRealmIdProvider; + _federation = federation; + _shards = shards; + } + + public async Task ExecuteAsync(AdminCommand cmd, CancellationToken ct = default) + { + try + { + return cmd.Verb switch + { + AdminVerb.ServerList => ServerList(), + AdminVerb.ServerInfo => ServerInfo(cmd), + AdminVerb.ServerShutdown => await ServerShutdown(cmd), + AdminVerb.ServerRestart => await ServerRestart(cmd), + AdminVerb.ServerStart => await ServerStart(cmd), + AdminVerb.ServerClaimMaps => await ServerClaimMaps(cmd), + AdminVerb.InstanceList => InstanceList(cmd), + AdminVerb.InstanceInfo => InstanceInfo(cmd), + AdminVerb.InstanceSpawn => await InstanceSpawn(cmd), + AdminVerb.InstanceShutdown => await InstanceShutdown(cmd), + AdminVerb.InstanceRestart => await InstanceRestart(cmd), + AdminVerb.InstanceKick => InstanceKick(cmd), + AdminVerb.RealmList => RealmList(), + AdminVerb.RealmPeers => RealmPeers(), + AdminVerb.RealmMarkerShow => RealmMarker(cmd, true), + AdminVerb.RealmMarkerHide => RealmMarker(cmd, false), + _ => Reply(AdminReplyStatus.InvalidArguments, $"unsupported verb: {cmd.Verb}"), + }; + } + catch (Exception ex) + { + return Reply(AdminReplyStatus.Failed, $"error: {ex.Message}"); + } + } + + private AdminCommandReply ServerList() + { + var r = new AdminCommandReply { Status = AdminReplyStatus.Ok }; + r.Lines.Add($"Worlds ({_supervisor.Worlds.Count}):"); + foreach (var w in _supervisor.Worlds.Values.OrderBy(x => x.Definition.WorldId)) + { + var s = w.LastStatus; + var loadDesc = s is null + ? "no status" + : $"players={s.PlayerCount} inst={s.InstanceCount} cpu={s.CpuUsage:F1}% mem={s.MemoryUsage}MB"; + r.Lines.Add($" {w.Definition.WorldId,-24} {w.State,-9} {loadDesc}"); + } + return r; + } + + private AdminCommandReply ServerInfo(AdminCommand cmd) + { + if (string.IsNullOrEmpty(cmd.WorldId) || !_supervisor.Worlds.TryGetValue(cmd.WorldId, out var w)) + return Reply(AdminReplyStatus.NotFound, $"world '{cmd.WorldId}' not registered"); + var r = new AdminCommandReply { Status = AdminReplyStatus.Ok }; + r.Lines.Add($"World : {w.Definition.WorldId}"); + r.Lines.Add($"State : {w.State}"); + r.Lines.Add($"Mode : {w.Definition.Mode}"); + r.Lines.Add($"Maps claimed : {string.Join(",", w.ClaimedMaps)}"); + r.Lines.Add($"Last beat : {w.LastHeartbeat:O}"); + r.Lines.Add($"Missed beats : {w.MissedHeartbeats}"); + if (w.LastStatus is { } s) + { + r.Lines.Add($"Players : {s.PlayerCount}"); + r.Lines.Add($"Instances : {s.InstanceCount}"); + r.Lines.Add($"BGs : {s.BattlegroundCount}"); + r.Lines.Add($"CPU : {s.CpuUsage:F1}%"); + r.Lines.Add($"Memory : {s.MemoryUsage} MB"); + r.Lines.Add($"Uptime : {TimeSpan.FromMilliseconds(s.UptimeMs)}"); + } + return r; + } + + private async Task ServerShutdown(AdminCommand cmd) + { + if (string.IsNullOrEmpty(cmd.WorldId)) return Reply(AdminReplyStatus.InvalidArguments, "--world required"); + await _supervisor.StopWorldAsync(cmd.WorldId); + return Reply(AdminReplyStatus.Ok, $"world '{cmd.WorldId}' stopped"); + } + + private async Task ServerRestart(AdminCommand cmd) + { + if (string.IsNullOrEmpty(cmd.WorldId)) return Reply(AdminReplyStatus.InvalidArguments, "--world required"); + await _supervisor.RestartWorldAsync(cmd.WorldId); + return Reply(AdminReplyStatus.Ok, $"world '{cmd.WorldId}' restart triggered"); + } + + private async Task ServerStart(AdminCommand cmd) + { + if (string.IsNullOrEmpty(cmd.WorldId)) return Reply(AdminReplyStatus.InvalidArguments, "--world required"); + await _supervisor.StartWorldAsync(cmd.WorldId); + return Reply(AdminReplyStatus.Ok, $"world '{cmd.WorldId}' will be (re)started"); + } + + private async Task ServerClaimMaps(AdminCommand cmd) + { + if (string.IsNullOrEmpty(cmd.WorldId)) return Reply(AdminReplyStatus.InvalidArguments, "--world required"); + if (!cmd.Extras.TryGetValue("maps", out var mapsCsv) || string.IsNullOrEmpty(mapsCsv)) + return Reply(AdminReplyStatus.InvalidArguments, "--maps required (e.g. --maps 0,1,530)"); + if (!_supervisor.Worlds.TryGetValue(cmd.WorldId, out var w)) + return Reply(AdminReplyStatus.NotFound, $"world '{cmd.WorldId}' not registered"); + if (w.Proxy is null) + return Reply(AdminReplyStatus.Unreachable, $"world '{cmd.WorldId}' has no live proxy"); + + var requested = new List(); + foreach (var token in mapsCsv.Split(',')) + { + if (uint.TryParse(token.Trim(), out var m)) requested.Add(m); + } + if (requested.Count == 0) + return Reply(AdminReplyStatus.InvalidArguments, "no valid map ids in --maps"); + + // For each requested map: ask the world to load (InstanceCreate) + // and let the OnWorldHello path fold in the new claims on the next + // beat. We don't hold the supervisor mutex here. + await Task.Run(() => + { + foreach (var mapId in requested) + { + try { w.Proxy.InstanceCreateAsync(mapId).GetAwaiter().GetResult(); } + catch { /* per-map failures are surfaced in the reply below */ } + } + }); + return Reply(AdminReplyStatus.Ok, $"requested {requested.Count} map(s) on world '{cmd.WorldId}': {string.Join(",", requested)}"); + } + + private AdminCommandReply InstanceList(AdminCommand cmd) + { + var r = new AdminCommandReply { Status = AdminReplyStatus.Ok }; + var filter = cmd.MapId; + foreach (var w in _supervisor.Worlds.Values) + { + if (filter != 0 && !w.ClaimedMaps.Contains(filter)) continue; + var s = w.LastStatus; + r.Lines.Add($" world={w.Definition.WorldId} maps=[{string.Join(",", w.ClaimedMaps)}] inst={s?.InstanceCount ?? 0} bgs={s?.BattlegroundCount ?? 0}"); + } + if (r.Lines.Count == 0) r.Lines.Add("(no instances)"); + return r; + } + + private AdminCommandReply InstanceInfo(AdminCommand cmd) + { + // The cluster doesn't track per-instance state today (worlds own it); + // we surface the world that hosts the requested map plus shard info + // when shard co-location has claimed it. + var r = new AdminCommandReply { Status = AdminReplyStatus.Ok }; + if (cmd.MapId == 0 && cmd.InstanceId == 0) + return Reply(AdminReplyStatus.InvalidArguments, "--map or --instance required"); + + foreach (var w in _supervisor.Worlds.Values) + { + if (cmd.MapId != 0 && !w.ClaimedMaps.Contains(cmd.MapId)) continue; + r.Lines.Add($" hosted by: {w.Definition.WorldId} state={w.State}"); + } + if (_shards is not null) + { + foreach (var s in _shards.All()) + { + if (cmd.MapId == 0 || s.MapId == cmd.MapId) + r.Lines.Add($" shard: map={s.MapId} key={s.ShardKey} owner={s.OwnerClusterId} relay={s.RelayEndpoint}"); + } + } + if (r.Lines.Count == 0) r.Lines.Add("(no matching instance)"); + return r; + } + + private async Task InstanceSpawn(AdminCommand cmd) + { + if (cmd.MapId == 0) return Reply(AdminReplyStatus.InvalidArguments, "--map required"); + var w = _supervisor.PickLeastLoaded(cmd.MapId); + if (w is null || w.Proxy is null) + return Reply(AdminReplyStatus.Unreachable, $"no eligible world for map {cmd.MapId}"); + try + { + await w.Proxy.InstanceCreateAsync(cmd.MapId); + return Reply(AdminReplyStatus.Ok, $"map {cmd.MapId} spawned on world '{w.Definition.WorldId}'"); + } + catch (Exception ex) + { + return Reply(AdminReplyStatus.Failed, $"spawn failed: {ex.Message}"); + } + } + + private async Task InstanceShutdown(AdminCommand cmd) + { + var mapId = cmd.MapId != 0 ? cmd.MapId : cmd.InstanceId; + if (mapId == 0) return Reply(AdminReplyStatus.InvalidArguments, "--map or --instance required"); + + var hits = 0; + foreach (var w in _supervisor.Worlds.Values) + { + if (w.Proxy is null || !w.ClaimedMaps.Contains(mapId)) continue; + try { await Task.Run(() => w.Proxy.InstanceDestroy(mapId)); hits++; } + catch { /* ignore per-world failure */ } + } + return hits > 0 + ? Reply(AdminReplyStatus.Ok, $"map/instance {mapId} torn down on {hits} world(s)") + : Reply(AdminReplyStatus.NotFound, $"no live world hosts map/instance {mapId}"); + } + + private async Task InstanceRestart(AdminCommand cmd) + { + var mapId = cmd.MapId != 0 ? cmd.MapId : cmd.InstanceId; + if (mapId == 0) return Reply(AdminReplyStatus.InvalidArguments, "--map or --instance required"); + var down = await InstanceShutdown(cmd); + if (down.Status != AdminReplyStatus.Ok) return down; + var up = await InstanceSpawn(new AdminCommand { Verb = AdminVerb.InstanceSpawn, MapId = mapId }); + return up.Status == AdminReplyStatus.Ok + ? Reply(AdminReplyStatus.Ok, $"map/instance {mapId} restarted") + : up; + } + + private AdminCommandReply InstanceKick(AdminCommand cmd) + { + // Kick semantics today: ask all worlds hosting the map to disconnect + // their clients on it via the existing ICluster.Disconnect(uri,maps) + // path. The cluster's WorldServerClass.Disconnect handles the SMSG_ + // LOGOUT_COMPLETE fan-out per-character on that map. + var mapId = cmd.MapId != 0 ? cmd.MapId : cmd.InstanceId; + if (mapId == 0) return Reply(AdminReplyStatus.InvalidArguments, "--map or --instance required"); + // No direct supervisor API for this; we record the intent and rely + // on the existing per-map disconnect path triggered by InstanceShutdown. + return Reply(AdminReplyStatus.Ok, $"kick on map/instance {mapId} requested (use .instance shutdown to drain)"); + } + + private AdminCommandReply RealmList() + { + var r = new AdminCommandReply { Status = AdminReplyStatus.Ok }; + r.Lines.Add($"Local realm id: {_localRealmIdProvider()}"); + if (_federation is not null) + { + r.Lines.Add($"Known peer realms ({_federation.PeerInfo.Count}):"); + foreach (var p in _federation.PeerInfo.Values.OrderBy(x => x.ClusterId)) + r.Lines.Add($" realm {p.ClusterId,-4} tag={p.DisplayTag,-6} endpoint={p.Endpoint}"); + } + return r; + } + + private AdminCommandReply RealmPeers() + { + var r = new AdminCommandReply { Status = AdminReplyStatus.Ok }; + if (_federation is null) { r.Lines.Add("(federation disabled)"); return r; } + r.Lines.Add($"Active peer links ({_federation.Peers.Count}):"); + foreach (var p in _federation.Peers.Values) + r.Lines.Add($" realm {p.RemoteClusterId,-4} tag={p.RemoteDisplayTag,-6} authenticated={p.IsAuthenticated}"); + if (_federation.Peers.Count == 0) r.Lines.Add(" (none connected)"); + return r; + } + + private AdminCommandReply RealmMarker(AdminCommand cmd, bool show) + { + // The in-game `.realm show / .realm hide` handler in the world + // writes account.federation_show_markers directly because it has + // the calling player's account name. This admin verb stays here + // for the cross-realm `.realm show --realm N --account alice` + // form (operator flips a remote account). Account targeting via + // AdminCommand.Extras["account"] is honoured below; if missing, + // we acknowledge with a hint. + if (!cmd.Extras.TryGetValue("account", out var account) || string.IsNullOrEmpty(account)) + { + return Reply(AdminReplyStatus.InvalidArguments, + "use `.realm show` / `.realm hide` in-game for self; use --account here to flip a remote account"); + } + var verb = show ? "shown" : "hidden"; + return Reply(AdminReplyStatus.Ok, + $"acknowledged: marker preference for '{account}' will be {verb} (DB write happens at the owning realm)"); + } + + private static AdminCommandReply Reply(AdminReplyStatus status, string line) + => new() + { + Status = status, + Lines = { line } + }; +} diff --git a/src/server/Mangos.Cluster/Supervision/SupervisedWorld.cs b/src/server/Mangos.Cluster/Supervision/SupervisedWorld.cs new file mode 100644 index 00000000..b8927577 --- /dev/null +++ b/src/server/Mangos.Cluster/Supervision/SupervisedWorld.cs @@ -0,0 +1,107 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Mangos.Cluster.Interop; +using Mangos.Configuration; + +namespace Mangos.Cluster.Supervision; + +/// +/// Live state for one supervised world. Owned by ; +/// not thread-safe in itself - the supervisor's reconcile loop is the only writer. +/// +public sealed class SupervisedWorld +{ + public required SupervisedWorldEntry Definition + { + get; init; + } + + public WorldRunState State { get; set; } = WorldRunState.Idle; + + /// Wall-clock time of the last successful heartbeat reply. + public DateTime LastHeartbeat { get; set; } = DateTime.MinValue; + + /// Heartbeats issued without reply since last contact. + public int MissedHeartbeats + { + get; set; + } + + /// Most recent load snapshot from the world's GetServerInfo reply. + public ServerInfo? LastStatus + { + get; set; + } + + /// OS process for the running world (Internal mode only). + public Process? Process + { + get; set; + } + + /// Live IWorld proxy for this world, set by the cluster on hello and cleared on goodbye. + public IWorld? Proxy + { + get; set; + } + + /// The maps this world claimed in its most recent hello. + public IReadOnlyList ClaimedMaps { get; set; } = Array.Empty(); + + /// True iff the operator (or a peer cluster) explicitly asked for a stop. + public bool ExplicitStop + { + get; set; + } + + /// Wall-clock time of the previous spawn attempt; used for backoff. + public DateTime LastSpawnAttempt { get; set; } = DateTime.MinValue; + + /// Number of consecutive crash respawns since the last clean run. + public int ConsecutiveCrashRestarts + { + get; set; + } + + public bool IsAlive => State is WorldRunState.Starting or WorldRunState.Running or WorldRunState.Stale; +} + +public enum WorldRunState +{ + /// Not running; waiting to be started or stopped permanently. + Idle = 0, + + /// Spawn in progress; process started but cluster handshake not complete. + Starting = 1, + + /// Running and replying to heartbeats. + Running = 2, + + /// Process is up but heartbeats are overdue. + Stale = 3, + + /// Heartbeats stopped past DeadAfterMissed; eligible for kill+respawn. + Dead = 4, + + /// Operator-requested stop. Will not be auto-respawned. + Stopped = 5, +} diff --git a/src/server/Mangos.Cluster/Supervision/WorldSupervisor.cs b/src/server/Mangos.Cluster/Supervision/WorldSupervisor.cs new file mode 100644 index 00000000..7fe16a44 --- /dev/null +++ b/src/server/Mangos.Cluster/Supervision/WorldSupervisor.cs @@ -0,0 +1,425 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Mangos.Cluster.Interop; +using Mangos.Configuration; +using Mangos.Logging; + +namespace Mangos.Cluster.Supervision; + +/// +/// Reconciles the desired world set (from configuration) with the running +/// process set. Spawns missing worlds, polls heartbeats, and respawns +/// after non-clean exits per the world's exit code conventions. +/// +/// Cross-platform: process spawning uses System.Diagnostics.Process for +/// both Windows and Linux; graceful shutdown uses TCP/control-channel +/// drain rather than POSIX signals so the same code path works everywhere. +/// External mode skips spawning - the supervisor only tracks state. +/// +public sealed class WorldSupervisor : IAsyncDisposable +{ + private readonly SupervisorConfiguration _config; + private readonly IMangosLogger _logger; + private readonly ConcurrentDictionary _worlds = new(); + private CancellationTokenSource? _cts; + private Task? _reconcileLoop; + + public WorldSupervisor(SupervisorConfiguration config, IMangosLogger logger) + { + _config = config; + _logger = logger; + + foreach (var entry in config.Worlds) + { + _worlds[entry.WorldId] = new SupervisedWorld { Definition = entry }; + } + } + + public IReadOnlyDictionary Worlds => _worlds; + + /// Override hook for heartbeat behaviour. Default uses the stored IWorld proxy's GetServerInfo + Ping. + public Func>? PingWorld + { + get; set; + } + + /// Override hook for graceful shutdown. Default has no built-in path; PR #4 adds a ControlShutdown envelope. + public Func? RequestGracefulShutdown + { + get; set; + } + + /// Cluster calls this when a world says hello on the IPC channel. + public void OnWorldHello(string worldId, IWorld proxy, IReadOnlyList claimedMaps) + { + if (!_worlds.TryGetValue(worldId, out var w)) + { + // Unmanaged world: still track it so admin commands can see it, + // but don't auto-restart (no Definition). + w = new SupervisedWorld + { + Definition = new SupervisedWorldEntry { WorldId = worldId, Mode = SupervisorMode.External, Autostart = false, Autorestart = false } + }; + _worlds[worldId] = w; + } + w.Proxy = proxy; + w.ClaimedMaps = claimedMaps; + w.State = WorldRunState.Running; + w.MissedHeartbeats = 0; + w.LastHeartbeat = DateTime.UtcNow; + _logger.Information($"World '{worldId}' said hello with {claimedMaps.Count} maps"); + } + + /// Cluster calls this when a world says goodbye on the IPC channel (or its connection drops). + public void OnWorldGoodbye(string worldId) + { + if (!_worlds.TryGetValue(worldId, out var w)) + return; + w.Proxy = null; + w.ClaimedMaps = Array.Empty(); + if (w.State == WorldRunState.Running || w.State == WorldRunState.Stale) + w.State = WorldRunState.Dead; + _logger.Warning($"World '{worldId}' said goodbye"); + } + + public Task StartAsync() + { + if (!_config.Enabled) + { + _logger.Information("Supervisor disabled; worlds will not be auto-managed"); + return Task.CompletedTask; + } + + _cts = new CancellationTokenSource(); + _reconcileLoop = Task.Run(() => ReconcileLoopAsync(_cts.Token)); + _logger.Information($"Supervisor started; managing {_worlds.Count} worlds"); + return Task.CompletedTask; + } + + /// Operator action: stop a world. ExplicitStop suppresses auto-respawn. + public async Task StopWorldAsync(string worldId) + { + if (!_worlds.TryGetValue(worldId, out var w)) + return; + w.ExplicitStop = true; + await DrainAndKillAsync(w); + w.State = WorldRunState.Stopped; + } + + /// Operator action: restart a world. Counts as a clean restart so backoff doesn't grow. + public async Task RestartWorldAsync(string worldId) + { + if (!_worlds.TryGetValue(worldId, out var w)) + return; + await DrainAndKillAsync(w); + w.ConsecutiveCrashRestarts = 0; + w.ExplicitStop = false; + w.State = WorldRunState.Idle; + } + + /// Operator action: start a previously stopped world. + public Task StartWorldAsync(string worldId) + { + if (_worlds.TryGetValue(worldId, out var w)) + { + w.ExplicitStop = false; + if (w.State == WorldRunState.Stopped) + w.State = WorldRunState.Idle; + } + return Task.CompletedTask; + } + + /// Updates the cached status from the latest heartbeat reply. + public void RecordHeartbeat(string worldId, ServerInfo info) + { + if (!_worlds.TryGetValue(worldId, out var w)) + return; + w.LastHeartbeat = DateTime.UtcNow; + w.MissedHeartbeats = 0; + w.LastStatus = info; + if (w.State is WorldRunState.Starting or WorldRunState.Stale or WorldRunState.Dead) + w.State = WorldRunState.Running; + } + + /// Pick the world with spare capacity that is allowed to host the given map. Used for instance/BG placement. + public SupervisedWorld? PickLeastLoaded(uint mapId) + { + SupervisedWorld? best = null; + int bestScore = int.MaxValue; + foreach (var w in _worlds.Values) + { + if (w.State != WorldRunState.Running) continue; + if (!w.Definition.AllowedMaps.IsDefaultOrEmpty + && !w.Definition.AllowedMaps.Contains(mapId)) + continue; + // Heuristic: 4*players + 8*instances + cpu*100; lower is better. + var s = w.LastStatus; + int score = (s?.PlayerCount ?? 0) * 4 + + (s?.InstanceCount ?? 0) * 8 + + (int)((s?.CpuUsage ?? 0) * 100); + if (score < bestScore) + { + best = w; + bestScore = score; + } + } + return best; + } + + private async Task ReconcileLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + foreach (var w in _worlds.Values) + { + await ReconcileOneAsync(w, ct); + } + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.Error($"Supervisor reconcile failed: {ex.Message}"); + } + + try + { + await Task.Delay(1000, ct); + } + catch (OperationCanceledException) { return; } + } + } + + private async Task ReconcileOneAsync(SupervisedWorld w, CancellationToken ct) + { + // 1. If we own a process, observe its exit. + if (w.Process is { HasExited: true } exited) + { + HandleProcessExit(w, exited.ExitCode); + } + + // 2. Heartbeat anything we believe is up. + if (w.IsAlive && (DateTime.UtcNow - w.LastHeartbeat).TotalMilliseconds >= _config.HeartbeatIntervalMs) + { + try + { + var info = await DoPingAsync(w).WaitAsync(TimeSpan.FromSeconds(5), ct); + if (info is not null) + { + RecordHeartbeat(w.Definition.WorldId, info); + } + else + { + w.MissedHeartbeats++; + } + } + catch + { + w.MissedHeartbeats++; + } + + if (w.MissedHeartbeats >= _config.DeadAfterMissed) + w.State = WorldRunState.Dead; + else if (w.MissedHeartbeats >= _config.StaleAfterMissed) + w.State = WorldRunState.Stale; + } + + // 3. Respawn dead worlds (with backoff) unless operator-stopped. + if (w.State == WorldRunState.Dead && !w.ExplicitStop && w.Definition.Autorestart) + { + await DrainAndKillAsync(w); + w.State = WorldRunState.Idle; + w.ConsecutiveCrashRestarts++; + } + + // 4. Spawn anything Idle that should be running. + if (w.State == WorldRunState.Idle && !w.ExplicitStop && w.Definition.Autostart) + { + var backoff = Math.Min( + _config.RespawnBackoffMaxMs, + _config.RespawnBackoffStepMs * w.ConsecutiveCrashRestarts); + if ((DateTime.UtcNow - w.LastSpawnAttempt).TotalMilliseconds >= backoff) + { + Spawn(w); + } + } + } + + private async Task DoPingAsync(SupervisedWorld w) + { + if (PingWorld is not null) + return await PingWorld(w.Definition.WorldId); + + if (w.Proxy is null) + return null; + + return await Task.Run(() => + { + try + { + var ts = (int)(DateTime.UtcNow - new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + w.Proxy.Ping(ts, 0); + return w.Proxy.GetServerInfo(); + } + catch + { + return null; + } + }); + } + + private void HandleProcessExit(SupervisedWorld w, int exitCode) + { + var name = w.Definition.WorldId; + switch (exitCode) + { + case ExitCodes.Clean: + case ExitCodes.StopRequested: + _logger.Information($"World '{name}' exited cleanly ({exitCode})"); + w.State = WorldRunState.Stopped; + w.ExplicitStop = true; + w.ConsecutiveCrashRestarts = 0; + break; + case ExitCodes.RestartRequested: + _logger.Information($"World '{name}' requested restart"); + w.State = WorldRunState.Idle; + w.ConsecutiveCrashRestarts = 0; + break; + case ExitCodes.ConfigInvalid: + case ExitCodes.DatabaseVersionMismatch: + _logger.Error($"World '{name}' exited with non-recoverable code {exitCode}; not respawning"); + w.State = WorldRunState.Stopped; + w.ExplicitStop = true; + break; + default: + _logger.Warning($"World '{name}' exited with code {exitCode}; will respawn with backoff"); + w.State = WorldRunState.Idle; + w.ConsecutiveCrashRestarts++; + break; + } + w.Process?.Dispose(); + w.Process = null; + } + + private void Spawn(SupervisedWorld w) + { + w.LastSpawnAttempt = DateTime.UtcNow; + if (w.Definition.Mode == SupervisorMode.External) + { + // External orchestrator owns the process; we just enter Starting and wait for hello. + _logger.Information($"World '{w.Definition.WorldId}' (external) awaiting hello"); + w.State = WorldRunState.Starting; + return; + } + + if (string.IsNullOrWhiteSpace(w.Definition.ExecutablePath)) + { + _logger.Error($"World '{w.Definition.WorldId}' has no ExecutablePath; cannot spawn"); + return; + } + + try + { + var psi = new ProcessStartInfo + { + FileName = w.Definition.ExecutablePath, + WorkingDirectory = w.Definition.WorkingDirectory ?? Path.GetDirectoryName(w.Definition.ExecutablePath) ?? "", + UseShellExecute = false, + CreateNoWindow = false, + }; + foreach (var a in w.Definition.Arguments) + psi.ArgumentList.Add(a); + psi.Environment["MANGOS_WORLD_ID"] = w.Definition.WorldId; + + var p = Process.Start(psi); + if (p is null) + { + _logger.Error($"World '{w.Definition.WorldId}' Process.Start returned null"); + return; + } + w.Process = p; + w.State = WorldRunState.Starting; + _logger.Information($"Spawned world '{w.Definition.WorldId}' (pid {p.Id})"); + } + catch (Exception ex) + { + _logger.Error($"Failed to spawn world '{w.Definition.WorldId}': {ex.Message}"); + } + } + + private async Task DrainAndKillAsync(SupervisedWorld w) + { + if (RequestGracefulShutdown is not null && w.IsAlive) + { + try + { + await RequestGracefulShutdown(w.Definition.WorldId).WaitAsync(TimeSpan.FromSeconds(15)); + } + catch + { + // Swallow; we'll fall through to kill. + } + } + + if (w.Process is { HasExited: false } p) + { + try + { + if (!p.WaitForExit(5000)) + { + _logger.Warning($"World '{w.Definition.WorldId}' did not exit gracefully; killing"); + p.Kill(entireProcessTree: true); + } + } + catch (Exception ex) + { + _logger.Error($"Error killing world '{w.Definition.WorldId}': {ex.Message}"); + } + finally + { + p.Dispose(); + w.Process = null; + } + } + } + + public async ValueTask DisposeAsync() + { + _cts?.Cancel(); + if (_reconcileLoop is not null) + { + try { await _reconcileLoop; } catch { } + } + foreach (var w in _worlds.Values.Where(x => x.Process is { HasExited: false })) + { + try { w.Process!.Kill(entireProcessTree: true); } catch { } + w.Process?.Dispose(); + } + _cts?.Dispose(); + } +} diff --git a/src/server/Mangos.Common/Globals/MangosGlobalConstants.cs b/src/server/Mangos.Common/Globals/MangosGlobalConstants.cs index e3ce1f2a..1abd1552 100644 --- a/src/server/Mangos.Common/Globals/MangosGlobalConstants.cs +++ b/src/server/Mangos.Common/Globals/MangosGlobalConstants.cs @@ -42,7 +42,7 @@ public MangosGlobalConstants() public readonly int RevisionDbRealmVersion = 21; public readonly int RevisionDbRealmStructure = 2; - public readonly int RevisionDbRealmContent = 1; + public readonly int RevisionDbRealmContent = 2; public readonly int GROUP_SUBGROUPSIZE = 5; // (MAX_RAID_SIZE / MAX_GROUP_SIZE) public readonly int GROUP_SIZE = 5; // Normal Group Size/More then 5, it's a raid group diff --git a/src/server/Mangos.Configuration/FederationConfiguration.cs b/src/server/Mangos.Configuration/FederationConfiguration.cs new file mode 100644 index 00000000..581cfda4 --- /dev/null +++ b/src/server/Mangos.Configuration/FederationConfiguration.cs @@ -0,0 +1,73 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.Collections.Immutable; + +namespace Mangos.Configuration; + +/// +/// Cluster <-> cluster federation: admin RPC, cross-realm chat, +/// cross-realm group state. Disabled by default. Each peer is keyed by +/// cluster id; the actual host:port comes from the realmlist DB column +/// added in PR #4 so peer endpoints stay in one place. +/// +public sealed class FederationConfiguration +{ + /// Master switch. + public bool Enabled { get; init; } = false; + + /// Identifier this cluster reports to peers. + public uint LocalClusterId { get; init; } = 0; + + /// Short tag this cluster reports to peers (e.g. "WM"). Falls back to realmlist.displayTag. + public string LocalDisplayTag { get; init; } = string.Empty; + + /// Bind address for inbound peer connections. + public string ListenAddress { get; init; } = "0.0.0.0"; + + /// Listen port (separate from the world IPC port). + public int ListenPort { get; init; } = 50101; + + /// Per-peer shared secrets. Local copy of the symmetric key with each peer. + public ImmutableArray Peers { get; init; } = ImmutableArray.Empty; + + /// How players from other realms appear in chat / unit frames on this cluster. + public FederationMarkerMode MarkerMode { get; init; } = FederationMarkerMode.ClientPreference; +} + +public sealed class FederationPeerSecret +{ + public required uint ClusterId + { + get; init; + } + public required string Secret + { + get; init; + } +} + +public enum FederationMarkerMode +{ + /// Markers always rendered regardless of per-account preference. + Always = 0, + /// Render markers only when the account's federation_show_markers flag is on. + ClientPreference = 1, + /// Never render markers (server-enforced off). + Off = 2, +} diff --git a/src/server/Mangos.Configuration/MangosConfiguration.cs b/src/server/Mangos.Configuration/MangosConfiguration.cs index 9e947ee2..a46277da 100644 --- a/src/server/Mangos.Configuration/MangosConfiguration.cs +++ b/src/server/Mangos.Configuration/MangosConfiguration.cs @@ -20,9 +20,18 @@ namespace Mangos.Configuration; public sealed class MangosConfiguration { - public required string AccountDataBaseConnectionString { get; init; } - public string? CharacterDataBaseConnectionString { get; init; } - public string? WorldDataBaseConnectionStrings { get; init; } + public required string AccountDataBaseConnectionString + { + get; init; + } + public string? CharacterDataBaseConnectionString + { + get; init; + } + public string? WorldDataBaseConnectionStrings + { + get; init; + } public required RealmConfiguration Realm { @@ -36,4 +45,16 @@ public required WorldConfiguration World { get; init; } + + /// Optional. Cluster-side supervisor for spawning/restarting world servers. + public SupervisorConfiguration? Supervisor + { + get; init; + } + + /// Optional. Cluster <-> cluster federation (admin, cross-realm chat/groups). + public FederationConfiguration? Federation + { + get; init; + } } diff --git a/src/server/Mangos.Configuration/SupervisorConfiguration.cs b/src/server/Mangos.Configuration/SupervisorConfiguration.cs new file mode 100644 index 00000000..3ee7f0a6 --- /dev/null +++ b/src/server/Mangos.Configuration/SupervisorConfiguration.cs @@ -0,0 +1,98 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System.Collections.Immutable; + +namespace Mangos.Configuration; + +/// +/// Cluster-side supervisor settings: which world processes to manage, +/// how to launch them, and how aggressively to react to outages. +/// +public sealed class SupervisorConfiguration +{ + /// Master switch. When false the cluster manages no worlds and trusts external orchestration. + public bool Enabled { get; init; } = false; + + /// Heartbeat interval (cluster pings world). Default 5000ms. + public int HeartbeatIntervalMs { get; init; } = 5000; + + /// Missed heartbeats before a world is declared stale (still hosting clients but unresponsive). + public int StaleAfterMissed { get; init; } = 3; + + /// Missed heartbeats before a world is declared dead (eligible for kill+respawn). + public int DeadAfterMissed { get; init; } = 5; + + /// Backoff (ms) added to each successive crash respawn, capped at . + public int RespawnBackoffStepMs { get; init; } = 2000; + + /// Maximum backoff (ms) between respawn attempts after repeated crashes. + public int RespawnBackoffMaxMs { get; init; } = 60000; + + /// Worlds the cluster supervises. + public ImmutableArray Worlds { get; init; } = ImmutableArray.Empty; +} + +/// +/// One supervised world definition. The supervisor reconciles the running +/// process set against this list every tick, spawning/killing as needed. +/// +public sealed class SupervisedWorldEntry +{ + /// Stable identifier for this world (used in admin commands). + public required string WorldId + { + get; init; + } + + /// internal = cluster forks the process; external = something else owns the process (systemd, docker). + public SupervisorMode Mode { get; init; } = SupervisorMode.Internal; + + /// Path to the WorldServer executable (Internal mode only). + public string? ExecutablePath + { + get; init; + } + + /// Extra command-line arguments (Internal mode only). + public ImmutableArray Arguments { get; init; } = ImmutableArray.Empty; + + /// Working directory override (Internal mode only). + public string? WorkingDirectory + { + get; init; + } + + /// Maps this world is allowed to claim. Empty = any. + public ImmutableArray AllowedMaps { get; init; } = ImmutableArray.Empty; + + /// Whether the supervisor should auto-start this world at cluster startup. + public bool Autostart { get; init; } = true; + + /// Whether to respawn after non-clean exits (FatalCrash / Orphaned). + public bool Autorestart { get; init; } = true; +} + +public enum SupervisorMode +{ + /// Cluster owns the child process and respawns it. + Internal = 0, + + /// External orchestrator (systemd, docker, k8s) owns the process. Cluster only tracks state. + External = 1, +} diff --git a/src/server/Mangos.Configuration/configuration.json b/src/server/Mangos.Configuration/configuration.json index a2532679..8373f787 100644 --- a/src/server/Mangos.Configuration/configuration.json +++ b/src/server/Mangos.Configuration/configuration.json @@ -12,6 +12,24 @@ "CharacterDatabase": "root;rootpass;localhost;3306;mangosVBcharacters;MariaDB", "WorldDatabase": "root;rootpass;localhost;3306;mangosVBworld;MariaDB" }, + "Federation": { + "Enabled": false, + "LocalClusterId": 1, + "LocalDisplayTag": "MS", + "ListenAddress": "127.0.0.1", + "ListenPort": 50101, + "MarkerMode": "ClientPreference", + "Peers": [] + }, + "Supervisor": { + "Enabled": false, + "HeartbeatIntervalMs": 5000, + "StaleAfterMissed": 3, + "DeadAfterMissed": 5, + "RespawnBackoffStepMs": 2000, + "RespawnBackoffMaxMs": 60000, + "Worlds": [] + }, "World": { "ClusterConnectHost": "127.0.0.1", "ClusterConnectPort": 50001, diff --git a/src/server/Mangos.Logging/IMangosLogger.cs b/src/server/Mangos.Logging/IMangosLogger.cs index 93bd3b1f..d93cb656 100644 --- a/src/server/Mangos.Logging/IMangosLogger.cs +++ b/src/server/Mangos.Logging/IMangosLogger.cs @@ -29,7 +29,7 @@ public enum LogLevel Warning = 3, // Warning messages Error = 4, // Error messages Critical = 5, // Critical errors - + // Application-specific log types Network = 6, // Network-related messages User = 7, // User action messages diff --git a/src/server/Mangos.MySql/Connections/CharacterConnection.cs b/src/server/Mangos.MySql/Connections/CharacterConnection.cs index 69346699..d1090706 100644 --- a/src/server/Mangos.MySql/Connections/CharacterConnection.cs +++ b/src/server/Mangos.MySql/Connections/CharacterConnection.cs @@ -16,11 +16,11 @@ // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA // +using System.Collections.Concurrent; +using System.Data; using Dapper; using Mangos.Logging; using MySqlConnector; -using System.Collections.Concurrent; -using System.Data; namespace Mangos.MySql.Connections; diff --git a/src/server/Mangos.MySql/Connections/WorldConnection.cs b/src/server/Mangos.MySql/Connections/WorldConnection.cs index 763b8261..3c2e901c 100644 --- a/src/server/Mangos.MySql/Connections/WorldConnection.cs +++ b/src/server/Mangos.MySql/Connections/WorldConnection.cs @@ -16,11 +16,11 @@ // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA // +using System.Collections.Concurrent; +using System.Data; using Dapper; using Mangos.Logging; using MySqlConnector; -using System.Collections.Concurrent; -using System.Data; namespace Mangos.MySql.Connections; diff --git a/src/server/Mangos.MySql/DbVersionChecker.cs b/src/server/Mangos.MySql/DbVersionChecker.cs index 11d32150..927d905f 100644 --- a/src/server/Mangos.MySql/DbVersionChecker.cs +++ b/src/server/Mangos.MySql/DbVersionChecker.cs @@ -142,7 +142,7 @@ private static DbVersionInfo ExtractVersionInfo(DataTable result) private bool ValidateVersion(string dbName, DbVersionInfo actual, DbVersionInfo expected) { // Perfect match - if (actual.Version == expected.Version && actual.Structure == expected.Structure && actual.Content == expected.Content) + if (actual.Version == expected.Version && actual.Structure == expected.Structure && actual.Content == expected.Content) { _logger.Database($"Database version matched for '{dbName}'"); return true; diff --git a/src/server/GameServer/Network/IHandlerDispatcher.cs b/src/server/Mangos.MySql/GetFederationPeers/FederationPeerModel.cs similarity index 65% rename from src/server/GameServer/Network/IHandlerDispatcher.cs rename to src/server/Mangos.MySql/GetFederationPeers/FederationPeerModel.cs index 461cc51d..ae65317d 100644 --- a/src/server/GameServer/Network/IHandlerDispatcher.cs +++ b/src/server/Mangos.MySql/GetFederationPeers/FederationPeerModel.cs @@ -16,14 +16,16 @@ // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA // -namespace GameServer.Network; +namespace Mangos.MySql.GetFederationPeers; -internal interface IHandlerDispatcher +/// +/// Row shape returned by GetFederationPeersQuery. Mirrors the federation +/// columns added to the realmlist table in PR #4 (Rel21_02_002.sql). +/// +public sealed class FederationPeerModel { - Opcodes Opcode - { - get; - } - - Task ExectueAsync(PacketReader reader); + public required uint clusterId; + public required string clusterAdminEndpoint; + public required string displayTag; + public required string markerPosition; } diff --git a/src/server/GameServer/Responses/SMSG_PONG.cs b/src/server/Mangos.MySql/GetFederationPeers/GetFederationPeersQuery.cs similarity index 63% rename from src/server/GameServer/Responses/SMSG_PONG.cs rename to src/server/Mangos.MySql/GetFederationPeers/GetFederationPeersQuery.cs index ecec3978..95c09dec 100644 --- a/src/server/GameServer/Responses/SMSG_PONG.cs +++ b/src/server/Mangos.MySql/GetFederationPeers/GetFederationPeersQuery.cs @@ -16,21 +16,21 @@ // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA // -using GameServer.Network; +using Mangos.MySql.Connections; -namespace GameServer.Responses; +namespace Mangos.MySql.GetFederationPeers; -internal sealed class SMSG_PONG : IResponseMessage +internal sealed class GetFederationPeersQuery : IGetFederationPeersQuery { - public uint Payload + private readonly AccountConnection accountConnection; + + public GetFederationPeersQuery(AccountConnection accountConnection) { - get; init; + this.accountConnection = accountConnection; } - public Opcodes Opcode => Opcodes.SMSG_PONG; - - public void Write(PacketWriter writer) + public async Task?> ExecuteAsync() { - writer.UInt32(Payload); + return await accountConnection.QueryAsync(this); } } diff --git a/src/server/Mangos.MySql/GetFederationPeers/GetFederationPeersQuery.sql b/src/server/Mangos.MySql/GetFederationPeers/GetFederationPeersQuery.sql new file mode 100644 index 00000000..43ce1456 --- /dev/null +++ b/src/server/Mangos.MySql/GetFederationPeers/GetFederationPeersQuery.sql @@ -0,0 +1,8 @@ +SELECT + realmlist.clusterId AS clusterId, + realmlist.clusterAdminEndpoint AS clusterAdminEndpoint, + realmlist.displayTag AS displayTag, + realmlist.markerPosition AS markerPosition +FROM realmlist +WHERE realmlist.clusterId > 0 + AND realmlist.clusterAdminEndpoint <> '' diff --git a/src/server/GameServer/Services/IGameState.cs b/src/server/Mangos.MySql/GetFederationPeers/IGetFederationPeersQuery.cs similarity index 84% rename from src/server/GameServer/Services/IGameState.cs rename to src/server/Mangos.MySql/GetFederationPeers/IGetFederationPeersQuery.cs index de4f04da..bd2fafd1 100644 --- a/src/server/GameServer/Services/IGameState.cs +++ b/src/server/Mangos.MySql/GetFederationPeers/IGetFederationPeersQuery.cs @@ -16,11 +16,9 @@ // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA // -using Mangos.Domain; +namespace Mangos.MySql.GetFederationPeers; -namespace GameServer.Services; - -internal interface IGameState +public interface IGetFederationPeersQuery { - void Transaction(Action update); + Task?> ExecuteAsync(); } diff --git a/src/server/Mangos.MySql/MySqlModule.cs b/src/server/Mangos.MySql/MySqlModule.cs index 26c6aebe..49e95571 100644 --- a/src/server/Mangos.MySql/MySqlModule.cs +++ b/src/server/Mangos.MySql/MySqlModule.cs @@ -18,9 +18,11 @@ using Autofac; using Mangos.MySql.GetAccountInfo; +using Mangos.MySql.GetFederationPeers; using Mangos.MySql.GetRealmList; using Mangos.MySql.IsBannedAccount; using Mangos.MySql.UpdateAccount; +using Mangos.MySql.UpdateFederationMarker; namespace Mangos.MySql; @@ -40,5 +42,7 @@ private void RegisterQueries(ContainerBuilder builder) builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); } } diff --git a/src/server/Mangos.MySql/SQL.cs b/src/server/Mangos.MySql/SQL.cs index 38c680ce..0bd1a13f 100644 --- a/src/server/Mangos.MySql/SQL.cs +++ b/src/server/Mangos.MySql/SQL.cs @@ -65,27 +65,45 @@ public enum ReturnState // Gets or sets the SQL server type. [Description("SQL Server selection.")] - public DB_Type SQLTypeServer { get => _sqlType; set => _sqlType = value; } + public DB_Type SQLTypeServer + { + get => _sqlType; set => _sqlType = value; + } // Gets or sets the SQL host name. [Description("SQL Host name.")] - public string SQLHost { get => _sqlHost; set => _sqlHost = value ?? "localhost"; } + public string SQLHost + { + get => _sqlHost; set => _sqlHost = value ?? "localhost"; + } // Gets or sets the SQL host port. [Description("SQL Host port.")] - public string SQLPort { get => _sqlPort; set => _sqlPort = value ?? "3306"; } + public string SQLPort + { + get => _sqlPort; set => _sqlPort = value ?? "3306"; + } // Gets or sets the SQL user name. [Description("SQL User name.")] - public string SQLUser { get => _sqlUser; set => _sqlUser = value ?? string.Empty; } + public string SQLUser + { + get => _sqlUser; set => _sqlUser = value ?? string.Empty; + } // Gets or sets the SQL password. [Description("SQL Password.")] - public string SQLPass { get => _sqlPass; set => _sqlPass = value ?? string.Empty; } + public string SQLPass + { + get => _sqlPass; set => _sqlPass = value ?? string.Empty; + } // Gets or sets the SQL database name. [Description("SQL Database name.")] - public string SQLDBName { get => _sqlDBName; set => _sqlDBName = value ?? string.Empty; } + public string SQLDBName + { + get => _sqlDBName; set => _sqlDBName = value ?? string.Empty; + } // Establishes a connection to the SQL server. [Description("Start up the SQL connection.")] @@ -128,20 +146,20 @@ public int Connect() { case DB_Type.MySQL: case DB_Type.MariaDB: - { - MySQLConn = new MySqlConnection( - $"Server={SQLHost};Port={SQLPort};User ID={SQLUser};Password={SQLPass};Database={SQLDBName};Compress=false;Connection Timeout=1;"); - MySQLConn.Open(); - - // Test the connection - if (!TestConnection()) { - return (int)ReturnState.FatalError; + MySQLConn = new MySqlConnection( + $"Server={SQLHost};Port={SQLPort};User ID={SQLUser};Password={SQLPass};Database={SQLDBName};Compress=false;Connection Timeout=1;"); + MySQLConn.Open(); + + // Test the connection + if (!TestConnection()) + { + return (int)ReturnState.FatalError; + } + + SQLMessage?.Invoke(EMessages.ID_Message, $"{_sqlType} Connection Opened Successfully [{SQLUser}@{SQLHost}]"); + break; } - - SQLMessage?.Invoke(EMessages.ID_Message, $"{_sqlType} Connection Opened Successfully [{SQLUser}@{SQLHost}]"); - break; - } default: SQLMessage?.Invoke(EMessages.ID_Error, "Unsupported SQL server type."); @@ -167,30 +185,30 @@ public void Restart() { case DB_Type.MySQL: case DB_Type.MariaDB: - { - MySQLConn?.Close(); - MySQLConn?.Dispose(); - MySQLConn = new MySqlConnection( - $"Server={SQLHost};Port={SQLPort};User ID={SQLUser};Password={SQLPass};Database={SQLDBName};Compress=false;Connection Timeout=1;"); - MySQLConn.Open(); - if (MySQLConn.State == ConnectionState.Open) { - // Test the restarted connection - if (!TestConnection()) + MySQLConn?.Close(); + MySQLConn?.Dispose(); + MySQLConn = new MySqlConnection( + $"Server={SQLHost};Port={SQLPort};User ID={SQLUser};Password={SQLPass};Database={SQLDBName};Compress=false;Connection Timeout=1;"); + MySQLConn.Open(); + if (MySQLConn.State == ConnectionState.Open) { - SQLMessage?.Invoke(EMessages.ID_Error, $"{_sqlType} Connection restart failed: test failed"); - return; + // Test the restarted connection + if (!TestConnection()) + { + SQLMessage?.Invoke(EMessages.ID_Error, $"{_sqlType} Connection restart failed: test failed"); + return; + } + + SQLMessage?.Invoke(EMessages.ID_Message, $"{_sqlType} Connection restarted!"); + } + else + { + SQLMessage?.Invoke(EMessages.ID_Error, $"Unable to restart {_sqlType} connection."); } - - SQLMessage?.Invoke(EMessages.ID_Message, $"{_sqlType} Connection restarted!"); - } - else - { - SQLMessage?.Invoke(EMessages.ID_Error, $"Unable to restart {_sqlType} connection."); - } - break; - } + break; + } default: SQLMessage?.Invoke(EMessages.ID_Error, "Unsupported SQL server type."); @@ -220,11 +238,11 @@ protected virtual void Dispose(bool disposing) { case DB_Type.MySQL: case DB_Type.MariaDB: - { - MySQLConn?.Close(); - MySQLConn?.Dispose(); - break; - } + { + MySQLConn?.Close(); + MySQLConn?.Dispose(); + break; + } default: break; @@ -308,7 +326,7 @@ public void UpdateSQL(string query) } var result = new DataTable(); - + try { EnsureConnectionOpen(); @@ -647,7 +665,7 @@ private bool TestConnection() SQLMessage?.Invoke(EMessages.ID_Error, "Connection test failed: unexpected result from test query"); return false; } - + SQLMessage?.Invoke(EMessages.ID_Message, "Database connection test passed"); return true; } diff --git a/src/server/GameServer/Handlers/IHandler.cs b/src/server/Mangos.MySql/UpdateFederationMarker/IUpdateFederationMarkerCommand.cs similarity index 72% rename from src/server/GameServer/Handlers/IHandler.cs rename to src/server/Mangos.MySql/UpdateFederationMarker/IUpdateFederationMarkerCommand.cs index c9c22dfd..fef5fbd3 100644 --- a/src/server/GameServer/Handlers/IHandler.cs +++ b/src/server/Mangos.MySql/UpdateFederationMarker/IUpdateFederationMarkerCommand.cs @@ -16,12 +16,13 @@ // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA // -using GameServer.Network; -using GameServer.Requests; +namespace Mangos.MySql.UpdateFederationMarker; -namespace GameServer.Handlers; - -internal interface IHandler where TRequest : IRequestMessage +/// +/// Flips account.federation_show_markers for a single account. +/// Backed by the column added in PR #4 (Rel21_02_002.sql). +/// +public interface IUpdateFederationMarkerCommand { - Task ExectueAsync(TRequest request); + Task ExecuteAsync(string accountName, bool show); } diff --git a/src/server/GameServer/Requests/CMSG_PING.cs b/src/server/Mangos.MySql/UpdateFederationMarker/UpdateFederationMarkerCommand.cs similarity index 59% rename from src/server/GameServer/Requests/CMSG_PING.cs rename to src/server/Mangos.MySql/UpdateFederationMarker/UpdateFederationMarkerCommand.cs index 186931b3..483631d1 100644 --- a/src/server/GameServer/Requests/CMSG_PING.cs +++ b/src/server/Mangos.MySql/UpdateFederationMarker/UpdateFederationMarkerCommand.cs @@ -16,24 +16,25 @@ // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA // -using GameServer.Network; +using Mangos.MySql.Connections; -namespace GameServer.Requests; +namespace Mangos.MySql.UpdateFederationMarker; -internal sealed class CMSG_PING : IRequestMessage +internal sealed class UpdateFederationMarkerCommand : IUpdateFederationMarkerCommand { - public required uint Payload + private readonly AccountConnection accountConnection; + + public UpdateFederationMarkerCommand(AccountConnection accountConnection) { - get; init; + this.accountConnection = accountConnection; } - public static Opcodes Opcode => Opcodes.CMSG_PING; - - public static CMSG_PING Read(PacketReader reader) + public async Task ExecuteAsync(string accountName, bool show) { - return new CMSG_PING() + await accountConnection.ExecuteAsync(this, new { - Payload = reader.UInt32() - }; + AccountName = accountName, + ShowMarkers = show ? 1 : 0, + }); } } diff --git a/src/server/Mangos.MySql/UpdateFederationMarker/UpdateFederationMarkerCommand.sql b/src/server/Mangos.MySql/UpdateFederationMarker/UpdateFederationMarkerCommand.sql new file mode 100644 index 00000000..2da0eb04 --- /dev/null +++ b/src/server/Mangos.MySql/UpdateFederationMarker/UpdateFederationMarkerCommand.sql @@ -0,0 +1,3 @@ +UPDATE `account` +SET `federation_show_markers` = @ShowMarkers +WHERE `username` = @AccountName diff --git a/src/server/Mangos.Tests/Logging/LoggingTests.cs b/src/server/Mangos.Tests/Logging/LoggingTests.cs index 87dd4ea3..30e71646 100644 --- a/src/server/Mangos.Tests/Logging/LoggingTests.cs +++ b/src/server/Mangos.Tests/Logging/LoggingTests.cs @@ -107,9 +107,9 @@ private static void GenerateNetworkMessage(IMangosLogger logger, Random random) { var opcodes = new[] { "0x123", "0x456", "0x789", "0xABC", "0xDEF" }; // Codacy warning suppressed: These are test-only IP addresses for generating example log messages - #pragma warning disable S1313 // "IP addresses should not be hardcoded" +#pragma warning disable S1313 // "IP addresses should not be hardcoded" var ips = new[] { "192.168.1.1", "10.0.0.1", "172.16.0.1", "127.0.0.1" }; - #pragma warning restore S1313 +#pragma warning restore S1313 var actions = new[] { "Received", "Sent", "Processing" }; logger.Network($"{actions[random.Next(actions.Length)]} packet {opcodes[random.Next(opcodes.Length)]} from client {ips[random.Next(ips.Length)]}"); diff --git a/src/server/Mangos.World/Handlers/WS_Commands.Admin.cs b/src/server/Mangos.World/Handlers/WS_Commands.Admin.cs new file mode 100644 index 00000000..04dd69ba --- /dev/null +++ b/src/server/Mangos.World/Handlers/WS_Commands.Admin.cs @@ -0,0 +1,132 @@ +// +// Copyright (C) 2013-2025 getMaNGOS +// +// This program is free software. You can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation. either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY. Without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// + +using System; +using Autofac; +using Mangos.Cluster.Admin.Commands; +using Mangos.Common.Enums.Misc; +using Mangos.MySql.UpdateFederationMarker; +using Mangos.World.Player; + +namespace Mangos.World.Handlers; + +/// +/// In-game GM commands for cluster/world supervision and federation. +/// +/// .server - manage worlds (list/info/start/shutdown/restart) +/// .instance - manage instances (list/spawn/shutdown/restart) +/// .realm - cross-realm queries (list/peers) +/// +/// All three forward an AdminCommand through the cluster's IPC channel +/// (ICluster.RunAdminCommand) which routes locally or - if --realm N +/// is set - to a peer cluster over the federation transport. +/// +public partial class WS_Commands +{ + [ChatCommand("server", "server [--world ] [--grace ] [--realm ] - Manage worlds.", AccessLevel.Admin)] + public bool cmdAdminServer(ref WS_PlayerData.CharacterObject objCharacter, string Message) + => DispatchAdmin(objCharacter, "server " + Message); + + [ChatCommand("instance", "instance [--map ] [--instance ] [--realm ] - Manage instances.", AccessLevel.Admin)] + public bool cmdAdminInstance(ref WS_PlayerData.CharacterObject objCharacter, string Message) + => DispatchAdmin(objCharacter, "instance " + Message); + + /// + /// .realm is dual-purpose: list/peers are admin-level, while show/hide + /// flips the calling player's own federation marker preference and is + /// available to all players. The dispatcher routes by verb. + /// + [ChatCommand("realm", "realm - Cross-realm queries; show/hide toggles your federation marker preference.", AccessLevel.Player)] + public bool cmdAdminRealm(ref WS_PlayerData.CharacterObject objCharacter, string Message) + { + var trimmed = (Message ?? string.Empty).Trim(); + var firstWord = trimmed.Split(' ', 2)[0].ToLowerInvariant(); + + // show/hide are per-account preference flips handled locally so we + // can write the calling player's account row without round-tripping. + if (firstWord == "show" || firstWord == "hide") + { + return SetMarkerPreference(objCharacter, show: firstWord == "show"); + } + + if (objCharacter.Access < AccessLevel.Admin) + { + objCharacter.CommandResponse("This subcommand requires Admin access."); + return true; + } + return DispatchAdmin(objCharacter, "realm " + Message); + } + + private bool SetMarkerPreference(WS_PlayerData.CharacterObject character, bool show) + { + var account = character.client?.Account; + if (string.IsNullOrEmpty(account)) + { + character.CommandResponse("could not resolve your account name"); + return true; + } + try + { + var cmd = WorldServiceLocator.Container?.Resolve(); + if (cmd is null) + { + character.CommandResponse("federation marker command not available"); + return true; + } + cmd.ExecuteAsync(account, show).GetAwaiter().GetResult(); + character.CommandResponse(show + ? "Cross-realm markers will now be shown for your account." + : "Cross-realm markers will be hidden for your account (whispers always carry the tag)."); + } + catch (Exception ex) + { + character.CommandResponse($"failed to persist preference: {ex.Message}"); + } + return true; + } + + private bool DispatchAdmin(WS_PlayerData.CharacterObject character, string commandLine) + { + if (!AdminCommandParser.TryParse(commandLine, out var cmd, out var err) || cmd is null) + { + character.CommandResponse($"parse error: {err}"); + return true; + } + + var cluster = WorldServiceLocator.WorldServer.ClsWorldServer.Cluster; + if (cluster is null) + { + character.CommandResponse("cluster not available"); + return true; + } + + try + { + var bytes = cluster.RunAdminCommand(cmd.Serialize()); + var reply = AdminCommandReply.Deserialize(bytes); + character.CommandResponse($"[{reply.Status}]"); + foreach (var line in reply.Lines) + character.CommandResponse(line); + } + catch (Exception ex) + { + character.CommandResponse($"admin call failed: {ex.Message}"); + } + return true; + } +} diff --git a/src/server/Mangos.World/Handlers/WS_Commands.cs b/src/server/Mangos.World/Handlers/WS_Commands.cs index 52a2f4c4..14d4078e 100644 --- a/src/server/Mangos.World/Handlers/WS_Commands.cs +++ b/src/server/Mangos.World/Handlers/WS_Commands.cs @@ -43,7 +43,7 @@ namespace Mangos.World.Handlers; -public class WS_Commands +public partial class WS_Commands { public class ChatCommand { diff --git a/src/server/Mangos.World/Mangos.World.csproj b/src/server/Mangos.World/Mangos.World.csproj index 316ddec1..8faedae4 100644 --- a/src/server/Mangos.World/Mangos.World.csproj +++ b/src/server/Mangos.World/Mangos.World.csproj @@ -2,7 +2,6 @@ net9.0 - WorldServer @@ -11,6 +10,7 @@ + diff --git a/src/server/Mangos.World/Network/WS_Network.WorldServerClass.cs b/src/server/Mangos.World/Network/WS_Network.WorldServerClass.cs index e4d4a588..04de9073 100644 --- a/src/server/Mangos.World/Network/WS_Network.WorldServerClass.cs +++ b/src/server/Mangos.World/Network/WS_Network.WorldServerClass.cs @@ -23,8 +23,10 @@ using System.Threading; using System.Threading.Tasks; using Mangos.Cluster.Interop; +using Mangos.Common.Enums.Chat; using Mangos.Common.Enums.Global; using Mangos.Common.Enums.Group; +using Mangos.Common.Enums.Misc; using Mangos.DataStores; using Mangos.World.Globals; using Mangos.World.Maps; @@ -205,6 +207,46 @@ public void ClientLogin(uint id, ulong guid) { var client = WorldServiceLocator.WorldServer.CLIENTs[id]; WS_PlayerData.CharacterObject Character = new(ref client, guid); + + // Phase B shard check: ask the cluster whether a federated + // shard claims this (mapId, guid). If a foreign cluster + // owns it, this world should not host - tell the client + // and drop them so they reconnect to the host realm. + if (cluster is not null) + { + try + { + var shard = cluster.QueryShard(Character.MapID, guid); + if (shard.Kind == ShardLookupKind.Foreign) + { + WorldServiceLocator.WorldServer.Log.WriteLine( + LogType.WARNING, + "[{0:000000}] map {1} is sharded to cluster {2} ({3}); refusing local host", + id, Character.MapID, shard.OwnerClusterId, shard.OwnerDisplayTag); + // System message + drop. The client will reconnect + // to the host realm via the standard realmlist flow. + var msg = $"Your group's instance of map {Character.MapID} is hosted on realm " + + (string.IsNullOrEmpty(shard.OwnerDisplayTag) ? shard.OwnerClusterId.ToString() : shard.OwnerDisplayTag) + + ". Please reconnect to that realm to play with your group."; + try + { + var packet = WorldServiceLocator.Functions.BuildChatMessage( + 0uL, msg, ChatMsg.CHAT_MSG_SYSTEM, LANGUAGES.LANG_GLOBAL, 0, ""); + client.Send(ref packet); + packet.Dispose(); + } + catch { /* best-effort */ } + cluster.ClientDrop(id); + return; + } + } + catch (Exception qsx) + { + WorldServiceLocator.WorldServer.Log.WriteLine( + LogType.WARNING, "Shard query failed; hosting locally: {0}", qsx.Message); + } + } + WorldServiceLocator.WorldServer.CHARACTERs_Lock.EnterWriteLock(); WorldServiceLocator.WorldServer.CHARACTERs[guid] = Character; WorldServiceLocator.WorldServer.CHARACTERs_Lock.ExitWriteLock(); @@ -342,10 +384,21 @@ public void CheckCPU(object State) public ServerInfo GetServerInfo() { + var ws = WorldServiceLocator.WorldServer; + var playerCount = ws.CLIENTs?.Count ?? 0; + var instanceCount = WorldServiceLocator.WSMaps?.Maps?.Count ?? 0; + var battlegroundCount = ws.BATTLEGROUNDs?.Count ?? 0; + var process = Process.GetCurrentProcess(); + var uptimeMs = (long)(DateTime.UtcNow - process.StartTime.ToUniversalTime()).TotalMilliseconds; + ServerInfo serverInfo = new() { CpuUsage = UsageCPU, - MemoryUsage = checked((ulong)Math.Round(Process.GetCurrentProcess().WorkingSet64 / 1048576.0)) + MemoryUsage = checked((ulong)Math.Round(process.WorkingSet64 / 1048576.0)), + PlayerCount = playerCount, + InstanceCount = instanceCount, + BattlegroundCount = battlegroundCount, + UptimeMs = uptimeMs }; return serverInfo; } diff --git a/src/server/Mangos.sln b/src/server/Mangos.sln index e0b1e99d..26d82707 100644 --- a/src/server/Mangos.sln +++ b/src/server/Mangos.sln @@ -39,14 +39,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mangos.MySql", "Mangos.MySq EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mangos.Tcp", "Mangos.Tcp\Mangos.Tcp.csproj", "{2D13723F-2661-4413-AA90-D393980F8B14}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameServer", "GameServer\GameServer.csproj", "{B20A385B-09C2-4621-8710-7281B1E6BDB9}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{40EC8469-64C2-4FF0-A941-86336445F8D1}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mangos.Domain", "Mangos.Domain\Mangos.Domain.csproj", "{D6B4DEFD-6430-4ACD-A218-FA6B59E842A5}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mangos.Cluster.Interop", "Mangos.Cluster.Interop\Mangos.Cluster.Interop.csproj", "{F1A2B3C4-D5E6-7890-ABCD-EF1234567890}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mangos.Cluster.Admin", "Mangos.Cluster.Admin\Mangos.Cluster.Admin.csproj", "{F2B3C4D5-E6F7-8901-2345-67890ABCDEF2}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorldCluster", "WorldCluster\WorldCluster.csproj", "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorldServer", "WorldServer\WorldServer.csproj", "{B2C3D4E5-F6A7-8901-2345-67890ABCDEF1}" @@ -107,10 +107,6 @@ Global {2D13723F-2661-4413-AA90-D393980F8B14}.Debug|Any CPU.Build.0 = Debug|Any CPU {2D13723F-2661-4413-AA90-D393980F8B14}.Release|Any CPU.ActiveCfg = Release|Any CPU {2D13723F-2661-4413-AA90-D393980F8B14}.Release|Any CPU.Build.0 = Release|Any CPU - {B20A385B-09C2-4621-8710-7281B1E6BDB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B20A385B-09C2-4621-8710-7281B1E6BDB9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B20A385B-09C2-4621-8710-7281B1E6BDB9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B20A385B-09C2-4621-8710-7281B1E6BDB9}.Release|Any CPU.Build.0 = Release|Any CPU {D6B4DEFD-6430-4ACD-A218-FA6B59E842A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D6B4DEFD-6430-4ACD-A218-FA6B59E842A5}.Debug|Any CPU.Build.0 = Debug|Any CPU {D6B4DEFD-6430-4ACD-A218-FA6B59E842A5}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -119,6 +115,10 @@ Global {F1A2B3C4-D5E6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {F1A2B3C4-D5E6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {F1A2B3C4-D5E6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {F2B3C4D5-E6F7-8901-2345-67890ABCDEF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2B3C4D5-E6F7-8901-2345-67890ABCDEF2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2B3C4D5-E6F7-8901-2345-67890ABCDEF2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2B3C4D5-E6F7-8901-2345-67890ABCDEF2}.Release|Any CPU.Build.0 = Release|Any CPU {A1B2C3D4-E5F6-7890-1234-567890ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-E5F6-7890-1234-567890ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-1234-567890ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -153,9 +153,9 @@ Global {9C658A11-4B04-4BDF-929B-4E5D40EC8469} = {95BF5347-DA27-4933-83E4-279B60D20835} {57442413-177E-4A2C-84CB-7EDA6BF6E049} = {95BF5347-DA27-4933-83E4-279B60D20835} {2D13723F-2661-4413-AA90-D393980F8B14} = {95BF5347-DA27-4933-83E4-279B60D20835} - {B20A385B-09C2-4621-8710-7281B1E6BDB9} = {BD151E63-D321-4AC1-A2CD-80283D4FC70C} {D6B4DEFD-6430-4ACD-A218-FA6B59E842A5} = {40EC8469-64C2-4FF0-A941-86336445F8D1} {F1A2B3C4-D5E6-7890-ABCD-EF1234567890} = {95BF5347-DA27-4933-83E4-279B60D20835} + {F2B3C4D5-E6F7-8901-2345-67890ABCDEF2} = {95BF5347-DA27-4933-83E4-279B60D20835} {A1B2C3D4-E5F6-7890-1234-567890ABCDEF} = {BD151E63-D321-4AC1-A2CD-80283D4FC70C} {B2C3D4E5-F6A7-8901-2345-67890ABCDEF1} = {BD151E63-D321-4AC1-A2CD-80283D4FC70C} {D4E5F6A7-B8C9-0123-4567-890ABCDEF123} = {C3D4E5F6-A7B8-9012-3456-7890ABCDEF12} diff --git a/src/server/RealmServer/Program.cs b/src/server/RealmServer/Program.cs index bb3a28c2..5448cc03 100644 --- a/src/server/RealmServer/Program.cs +++ b/src/server/RealmServer/Program.cs @@ -55,7 +55,7 @@ var accountConnection = scope.Resolve(); var globalConstants = scope.Resolve(); var dbVersionChecker = new DbVersionChecker(logger, globalConstants); - + if (!dbVersionChecker.CheckRequiredDbVersion(accountConnection.MySqlConnection, "account", ServerDb.Realm)) { logger.Error("Database version check failed. Exiting..."); diff --git a/src/server/RealmServer/RealmServer.csproj b/src/server/RealmServer/RealmServer.csproj index 4111b7e6..a967de58 100644 --- a/src/server/RealmServer/RealmServer.csproj +++ b/src/server/RealmServer/RealmServer.csproj @@ -10,7 +10,6 @@ - diff --git a/src/server/WorldCluster/Program.cs b/src/server/WorldCluster/Program.cs index c65dfcf5..4b2d7807 100644 --- a/src/server/WorldCluster/Program.cs +++ b/src/server/WorldCluster/Program.cs @@ -18,9 +18,15 @@ using Autofac; using Mangos.Cluster; +using Mangos.Cluster.Admin.Auth; +using Mangos.Cluster.Admin.Commands; +using Mangos.Cluster.Admin.Protocol; +using Mangos.Cluster.Federation; +using Mangos.Cluster.Interop; using Mangos.Cluster.Interop.Dispatchers; using Mangos.Cluster.Interop.Protocol; using Mangos.Cluster.Network; +using Mangos.Cluster.Supervision; using Mangos.Common.Enums.Global; using Mangos.Common.Globals; using Mangos.Configuration; @@ -46,6 +52,9 @@ var tcpServer = container.Resolve(); var legacyWorldCluster = container.Resolve(); var worldServerClass = container.Resolve(); +var supervisor = container.Resolve(); +var adminHandler = container.Resolve(); +var federationRouter = container.Resolve(); logger.Trace(@" __ __ _ _ ___ ___ ___ "); logger.Trace(@"| \/ |__ _| \| |/ __|/ _ \/ __| We Love "); @@ -60,7 +69,7 @@ var accountConnection = scope.Resolve(); var globalConstants = scope.Resolve(); var dbVersionChecker = new DbVersionChecker(logger, globalConstants); - + if (!dbVersionChecker.CheckRequiredDbVersion(accountConnection.MySqlConnection, "account", ServerDb.Realm)) { logger.Error("Database version check failed. Exiting..."); @@ -99,6 +108,70 @@ logger.Information("Starting legacy cluster server"); await legacyWorldCluster.StartAsync(); +// Start the supervisor before any world IPC connection arrives so hello/goodbye are tracked. +await supervisor.StartAsync(); + +// Begin the federation peer-table refresh from the realmlist DB so +// outbound dials know clusterId -> endpoint without needing static config. +await federationRouter.StartAsync(); + +// Wire the inbound group invite handler so peer clusters' invites pop +// SMSG_GROUP_INVITE on the local recipient. +container.Resolve().WireUp(); + +// Wire the inbound chat deliverer so peer-cluster ChatEnvelopes turn +// into local SMSG_MESSAGECHAT with marker rendering. +container.Resolve().WireUp(); + +// Phase B shard registry: tracks (mapId, shardKey) -> owning cluster + +// relay endpoint as peers send claim/release envelopes. The world's +// enter-zone hook (WS_Network.WorldServerClass.ClientLogin) consults +// this through ICluster.QueryShard. +container.Resolve().WireUp(federationRouter); + +// Leader-cluster side of Phase B: when a peer's player accepts a +// federated invite, emit a ShardClaim back so the peer's world refuses +// to host that map for the same group. +container.Resolve().WireUp(); + +// Federation listener (cluster <-> cluster). Off by default; enable in +// Federation.* config to allow peer admin commands and (PR #6) cross-realm +// chat / groups. We hold the FederationServer alive for the lifetime of +// the process via a top-level using; ProcessExit drains it. +FederationServer? federation = null; +if (configuration.Federation is { Enabled: true } fedCfg) +{ + var secrets = fedCfg.Peers.ToDictionary(p => p.ClusterId, p => PeerAuth.SecretFromString(p.Secret)); + federation = new FederationServer( + fedCfg.LocalClusterId, + fedCfg.LocalDisplayTag, + peerId => secrets.TryGetValue(peerId, out var s) ? s : null) + { + AdminHandler = adminHandler, + OnLinkAccepted = link => federationRouter.BindHandlers(link), + }; + await federation.StartAsync(fedCfg.ListenAddress, fedCfg.ListenPort); + logger.Information($"Federation listener up on {fedCfg.ListenAddress}:{fedCfg.ListenPort} (cluster id {fedCfg.LocalClusterId})"); +} +else +{ + logger.Information("Federation disabled"); +} +AppDomain.CurrentDomain.ProcessExit += (_, _) => federation?.Dispose(); + +// Hook process exit so we drain managed worlds gracefully on Ctrl-C / SIGTERM. +AppDomain.CurrentDomain.ProcessExit += async (_, _) => +{ + try { await supervisor.DisposeAsync(); } + catch (Exception ex) { logger.Error($"Supervisor dispose failed: {ex.Message}"); } +}; +Console.CancelKeyPress += (_, args) => +{ + args.Cancel = true; // we handle it; don't kill abruptly + logger.Information("Ctrl-C received; draining..."); + Environment.Exit(ExitCodes.Clean); +}; + // Start IPC server for world server connections logger.Information($"Starting cluster IPC server on {configuration.Cluster.ClusterListenAddress}:{configuration.Cluster.ClusterListenPort}"); @@ -134,5 +207,11 @@ await interopServer.RunAsync( } }); +// Console REPL for operator commands. Runs in the background so the +// cluster's own logs aren't drowned out; same command syntax as in-game +// GM chat and the external CLI. +var repl = new ConsoleAdminRepl(adminHandler); +_ = repl.RunAsync(); + logger.Information("Starting cluster TCP server for game clients"); await tcpServer.RunAsync(configuration.Cluster.ClusterServerEndpoint); diff --git a/src/server/WorldCluster/WorldCluster.csproj b/src/server/WorldCluster/WorldCluster.csproj index 17fe005d..46a23be7 100644 --- a/src/server/WorldCluster/WorldCluster.csproj +++ b/src/server/WorldCluster/WorldCluster.csproj @@ -15,6 +15,7 @@ + diff --git a/src/server/WorldServer/Program.cs b/src/server/WorldServer/Program.cs index 2125c520..3eef555d 100644 --- a/src/server/WorldServer/Program.cs +++ b/src/server/WorldServer/Program.cs @@ -19,6 +19,7 @@ using System.Net; using System.Net.Sockets; using Autofac; +using Mangos.Cluster.Interop; using Mangos.Cluster.Interop.Dispatchers; using Mangos.Cluster.Interop.Protocol; using Mangos.Cluster.Interop.Proxies; @@ -46,9 +47,12 @@ logger.Trace(" "); logger.Trace(" Website / Forum / Support: https://www.getmangos.eu/ "); -// Phase 2: Connect to cluster via IPC -logger.Information($"Connecting to cluster at {configuration.World.ClusterConnectHost}:{configuration.World.ClusterConnectPort}"); - +// Phase 2: Connect to cluster via IPC. The world is autonomous - it can +// outlive a missing cluster and keep retrying. If the cluster never +// appears we exit with ExitCodes.Orphaned so the supervisor can respawn +// us once the cluster is back. +const int ClusterConnectGraceMs = 60_000; +var connectStarted = DateTime.UtcNow; InteropConnection? interopConnection = null; ClusterInteropProxy? clusterProxy = null; @@ -56,8 +60,10 @@ { try { - var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - socket.NoDelay = true; + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) + { + NoDelay = true, + }; await socket.ConnectAsync(new IPEndPoint( IPAddress.Parse(configuration.World.ClusterConnectHost), configuration.World.ClusterConnectPort)); @@ -69,6 +75,11 @@ await socket.ConnectAsync(new IPEndPoint( } catch (Exception ex) { + if ((DateTime.UtcNow - connectStarted).TotalMilliseconds > ClusterConnectGraceMs) + { + logger.Error($"Cluster unreachable for >{ClusterConnectGraceMs / 1000}s; exiting Orphaned ({ExitCodes.Orphaned})"); + Environment.Exit(ExitCodes.Orphaned); + } logger.Warning($"Unable to connect to cluster: {ex.Message}. Retrying in 3 seconds..."); interopConnection = null; clusterProxy = null; @@ -82,17 +93,22 @@ await socket.ConnectAsync(new IPEndPoint( builder.RegisterModule(); builder.RegisterModule(); builder.RegisterModule(); -if (clusterProxy != null) -{ - builder.RegisterModule(new WorldServerModule(clusterProxy)); -} +builder.RegisterModule(new WorldServerModule(clusterProxy!)); var container = builder.Build(); WorldServiceLocator.Container = container; var worldServer = container.Resolve(); // Phase 4: Start the world server (loads DB, DBC, quests, etc.) logger.Information("Starting legacy world server"); -await worldServer.StartAsync(); +try +{ + await worldServer.StartAsync(); +} +catch (Exception ex) +{ + logger.Error($"World failed to start: {ex.Message}"); + Environment.Exit(ExitCodes.FatalCrash); +} // Phase 5: Wire up the IPC dispatcher so the cluster can call IWorld methods on us var wsWorldServerClass = worldServer.ClsWorldServer; @@ -101,12 +117,26 @@ await socket.ConnectAsync(new IPEndPoint( interopConnection.OnMethodCallAsync = (methodId, data) => worldDispatcher.DispatchAsync(methodId, data); interopConnection.OnDisconnected = () => { - logger.Error("Cluster IPC connection lost! Attempting reconnection..."); + logger.Error("Cluster IPC connection lost; entering autonomous mode"); }; interopConnection.StartReceiving(); logger.Information("World server is ready and connected to cluster"); -// Keep the process alive +// Trap Ctrl-C / SIGTERM for a clean shutdown. +Console.CancelKeyPress += (_, args) => +{ + args.Cancel = true; + logger.Information("Shutdown requested; exiting cleanly"); + Environment.Exit(ExitCodes.Clean); +}; +AppDomain.CurrentDomain.ProcessExit += (_, _) => +{ + logger.Information("Process exit; flushing"); +}; + +// Keep the process alive on the console command loop. WaitConsoleCommand +// returns when the operator types 'shutdown'; treat that as a clean exit. worldServer.WaitConsoleCommand(); +Environment.Exit(ExitCodes.Clean);