From 4af48cbef796c89ce99daeb18b738342763958c5 Mon Sep 17 00:00:00 2001 From: Blank Date: Tue, 22 Sep 2026 15:33:58 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(network):=20=E7=8E=A9=E5=AE=B6?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E6=A0=B8=E5=BF=83=E5=B1=82=E8=A3=85=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 NetWork.RemoteMessaging 包内实现跨服玩家路由层(C143e D21): player_route 控制库集合 + IPlayerRouteResolver 三级查找接口 + Mongo 版本 CAS 同步目标 + 装配入口 + 信封接收端 LocalEnvelopeDispatcher。 新增 IPlayerRouteFastPath 反向依赖解耦缝,避免 RemoteMessaging 引用 GameFrameX.Apps。MongoDiscoveryRuntime.Activate 末尾追加 Bootstrap 装配。 RoleRouteEnvelopeMessage 启用 MessageTypeHandler 特性;RoleRouterHolder 暴露 IsInitialized。PerInstanceDedupe 提供跨服投递 1 秒单飞窗口。 --- .../Discovery/MongoDiscoveryRuntime.cs | 6 +- .../Routing/IPlayerRouteFastPath.cs | 37 +++ .../Routing/IPlayerRouteResolver.cs | 36 +++ .../Routing/IPlayerRouteSyncTarget.cs | 57 +++++ .../Routing/LocalEnvelopeDispatcher.cs | 113 ++++++++++ .../Routing/MongoPlayerRouteResolver.cs | 152 +++++++++++++ .../MongoPlayerRouteResolverBootstrap.cs | 88 ++++++++ .../Routing/MongoPlayerRouteSyncTarget.cs | 108 +++++++++ .../Routing/NullPlayerRouteSyncTarget.cs | 46 ++++ .../Routing/PerInstanceDedupe.cs | 114 ++++++++++ .../Routing/PlayerRouteCollection.cs | 211 ++++++++++++++++++ .../Routing/PlayerRouteInfo.cs | 89 ++++++++ .../Routing/PlayerRouteStaleException.cs | 67 ++++++ .../Routing/RoleRouteEnvelopeMessage.cs | 8 +- .../Routing/RoleRouterHolder.cs | 13 ++ 15 files changed, 1141 insertions(+), 4 deletions(-) create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteFastPath.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteResolver.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteSyncTarget.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/LocalEnvelopeDispatcher.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteResolver.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteResolverBootstrap.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteSyncTarget.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/NullPlayerRouteSyncTarget.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/PerInstanceDedupe.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteCollection.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteInfo.cs create mode 100644 GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteStaleException.cs diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoDiscoveryRuntime.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoDiscoveryRuntime.cs index 6f08b3f9..0e747f9b 100644 --- a/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoDiscoveryRuntime.cs +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoDiscoveryRuntime.cs @@ -86,8 +86,9 @@ public static class MongoDiscoveryRuntime /// /// 控制库(gameframex_control)/ The control database /// 本进程承载的 Role 名全集(RoleSet 快照)/ The full hosted role-name set (the RoleSet snapshot) + /// Tier 1 玩家路由快路径提供方(apps 端 SessionManager 适配器;null 则跳过 Tier 1)/ Tier 1 fast path; null skips Tier 1 /// 当 或 为 null 时抛出 / Thrown when controlDatabase or hostedRoleNames is null - public static void Activate(IMongoDatabase controlDatabase, IEnumerable hostedRoleNames) + public static void Activate(IMongoDatabase controlDatabase, IEnumerable hostedRoleNames, IPlayerRouteFastPath playerRouteFastPath = null) { ArgumentNullException.ThrowIfNull(controlDatabase, nameof(controlDatabase)); ArgumentNullException.ThrowIfNull(hostedRoleNames, nameof(hostedRoleNames)); @@ -118,6 +119,9 @@ public static void Activate(IMongoDatabase controlDatabase, IEnumerable } RoleRouterHolder.Initialize(new InProcessRoleRouter(hostedRoles, null, new MongoDiscoveryRemoteRoleRouter(_watcher, new TcpEnvelopeForwarder()))); + // C143e D21:玩家路由层装配(建索引 + 装 SyncTarget)。在路由缝激活后追加; + // 接收端 envelope 复投由 LocalEnvelopeDispatcher 承担,LocalEnvelopeDispatcher 在装配流程末尾(GameApp)按需注入,本类不直接重装 RoleRouterHolder。 + MongoPlayerRouteResolverBootstrap.Attach(controlDatabase, playerRouteFastPath).GetAwaiter().GetResult(); } /// diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteFastPath.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteFastPath.cs new file mode 100644 index 00000000..deca45d4 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteFastPath.cs @@ -0,0 +1,37 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 玩家路由 Tier 1 快路径提供方(C143e D21)。 +/// +/// +/// The Tier 1 player-route fast-path provider (C143e D21). The default +/// implementation in GameFrameX.Apps is the in-process +/// SessionManager.PlayerRouteMap; it is injected into +/// at launch time so the resolver does +/// not depend on the host application layer. Returning false (or +/// with = false) +/// makes the resolver fall through to the Tier 2 control-database lookup. +/// +public interface IPlayerRouteFastPath +{ + /// + /// 查询玩家是否在本进程在线。 + /// + /// + /// Checks whether the player is locally online. Returning false makes + /// the resolver fall through to Tier 2. + /// + /// 玩家 ID / The player id + /// 在线态返回本地缓存的路由信息 / The locally cached route info when online + /// 是否本地在线 / Whether the player is locally online + bool TryGetOnline(long playerId, out PlayerRouteInfo info); +} \ No newline at end of file diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteResolver.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteResolver.cs new file mode 100644 index 00000000..9c6fd765 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteResolver.cs @@ -0,0 +1,36 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 玩家路由解析抽象(C143e D21)。 +/// +/// +/// The player-route resolver contract (C143e D21). Implementations resolve a +/// player's current logical location (which role + numeric serverId +/// holds them, or that they are offline) by looking through some set of tiers — +/// typically in-process memory first, then a shared control store. The default +/// Mongo-backed implementation is ; the +/// bootstrap swaps it in once the control database is registered. Returning a +/// cached or negative answer is allowed: the caller treats +/// as the binding signal. +/// +public interface IPlayerRouteResolver +{ + /// + /// 解析玩家当前路由位置。 + /// + /// + /// Resolves the player's current routing location. + /// + /// 玩家 ID / The player id + /// 玩家路由信息 — 在线时含 role + serverId;离线时仅 IsOnline=false / The route info — online carries role + serverId; offline returns IsOnline=false + Task ResolveAsync(long playerId); +} \ No newline at end of file diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteSyncTarget.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteSyncTarget.cs new file mode 100644 index 00000000..3f5882e2 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/IPlayerRouteSyncTarget.cs @@ -0,0 +1,57 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// Any legal disputes and liabilities arising from secondary development based on this project +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 玩家路由外发同步目标(C143e D21:SessionManager 钩子的可注入端)。 +/// +/// +/// The outbound sync target for the SessionManager player-route hooks (C143e D21). +/// The hook is async-by-design so the Mongo implementation can await +/// the CAS upsert; the NoOp default returns immediately. Callers (SessionManager) +/// must catch and swallow exceptions themselves — the inline hook contract is +/// "best effort, never throws". This interface deliberately exposes primitive +/// fields instead of a struct/DTO to keep GameFrameX.Apps free of Mongo types +/// (the SyncTarget type lives in RemoteMessaging, which Apps already references). +/// +public interface IPlayerRouteSyncTarget +{ + /// + /// 把 (playerId, instanceId, role, version) 原子写入控制库 player_route(version CAS)。 + /// + /// + /// Atomically writes the (playerId, instanceId, role, version) tuple into the + /// control-database player_route collection using version as the + /// CAS key. The Mongo implementation throws + /// when the persisted version is already ahead of the supplied value. + /// + /// 玩家 ID / Player id + /// 实例 ID / Instance id + /// Role 名 / Role name + /// 顶号版本号 / Kick/relogin version + /// 异步任务 / Async task + Task UpsertAsync(long playerId, string instanceId, string role, long version); + + /// + /// 从控制库 player_route 删除该玩家路由。 + /// + /// + /// Removes the player's route document from the control-database + /// player_route collection. Missing documents are silently ignored + /// (idempotent semantics; a stale cleanup is harmless). + /// + /// 玩家 ID / Player id + /// 异步任务 / Async task + Task DeleteAsync(long playerId); +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/LocalEnvelopeDispatcher.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/LocalEnvelopeDispatcher.cs new file mode 100644 index 00000000..fd9dc9b2 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/LocalEnvelopeDispatcher.cs @@ -0,0 +1,113 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +using GameFrameX.NetWork.Abstractions; +using GameFrameX.NetWork.Messages; +using GameFrameX.NetWork.RemoteMessaging.Unified; +using GameFrameX.ProtoBuf.Net; +using GameFrameX.Foundation.Logger; + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 本地 envelope 复投器(C143e:case 1 命中时把 envelope 解包成本服本地投递)。 +/// +/// +/// The local envelope dispatcher for D3 case 1 (C143e). When the routing seam +/// hands an envelope to the local dispatcher, the receiving end is responsible +/// for unpacking the embedded +/// and into a concrete +/// and re-entering the local delivery pipeline via +/// the injected — which is exactly what the +/// existing SendToPlayerInnerHandler does for the RPC envelope path. We +/// mirror that semantic on the cross-process envelope path so the two transports +/// stay interchangeable. +/// ponytail: 失败时不抛(默认 Drop 策略);保证路由缝不会因 envelope 解包异常而把整个目标服 +/// 拖进无限重试。仅日志 warning,由下一次发件方轮询 / TTL 兜底。 +/// +public sealed class LocalEnvelopeDispatcher : ILocalRoleMessageDispatcher +{ + private readonly IPlayerLocalSender _localSender; + private readonly PlayerOfflineStrategy _offlineStrategy; + + /// + /// 初始化本地 envelope 复投器。 + /// + /// + /// Initializes the dispatcher with the local sender used for the actual + /// delivery and the strategy to apply when the target player is no longer + /// online (rare race window: heartbeat says Active but SessionManager has + /// already removed the session between send and receive). + /// + /// 本服玩家发送器 / The local player sender + /// 离线处理策略(默认 Drop:仅日志,不抛)/ Offline handling strategy (defaults to Drop: log only) + public LocalEnvelopeDispatcher(IPlayerLocalSender localSender, PlayerOfflineStrategy offlineStrategy = PlayerOfflineStrategy.Discard) + { + ArgumentNullException.ThrowIfNull(localSender, nameof(localSender)); + _localSender = localSender; + _offlineStrategy = offlineStrategy; + } + + /// + public async Task DispatchAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope, nameof(envelope)); + + var envelopeMessage = envelope.Message as RoleRouteEnvelopeMessage; + if (envelopeMessage == null) + { + LogHelper.Warning("[LocalEnvelopeDispatcher] envelope message is not RoleRouteEnvelopeMessage (actual type: {actual}); dropping", envelope.Message?.GetType().FullName ?? "null"); + return; + } + + var innerType = MessageProtoHelper.GetMessageTypeById(envelopeMessage.InnerMessageId); + if (innerType == null) + { + LogHelper.Warning("[LocalEnvelopeDispatcher] inner message id {innerMessageId} is not registered in MessageProtoHelper; dropping envelope for target {targetActorId}", envelopeMessage.InnerMessageId, envelope.TargetActorId); + return; + } + + MessageObject innerMessage; + try + { + innerMessage = (MessageObject)ProtoBufSerializerHelper.Deserialize(envelopeMessage.InnerMessageBytes ?? Array.Empty(), innerType); + } + catch (Exception exception) + { + LogHelper.Error(exception, "[LocalEnvelopeDispatcher] failed to deserialize inner message for target {targetActorId} (innerMessageId={innerMessageId}); dropping", envelope.TargetActorId, envelopeMessage.InnerMessageId); + return; + } + + if (envelope.TargetActorId <= 0) + { + LogHelper.Warning("[LocalEnvelopeDispatcher] envelope has no TargetActorId (sender bug?); dropping inner message id={innerMessageId}", envelopeMessage.InnerMessageId); + return; + } + + if (!_localSender.IsPlayerOnline(envelope.TargetActorId)) + { + switch (_offlineStrategy) + { + case PlayerOfflineStrategy.StoreOffline: + LogHelper.Info("[LocalEnvelopeDispatcher] target {targetActorId} is offline; StoreOffline strategy placeholder — message dropped (no offline store yet)", envelope.TargetActorId); + break; + case PlayerOfflineStrategy.Discard: + LogHelper.Info("[LocalEnvelopeDispatcher] target {targetActorId} is offline; Discard strategy — message dropped", envelope.TargetActorId); + break; + default: + LogHelper.Info("[LocalEnvelopeDispatcher] target {targetActorId} is offline; default strategy — message dropped", envelope.TargetActorId); + break; + } + + return; + } + + await _localSender.SendToLocalPlayerAsync(envelope.TargetActorId, innerMessage).ConfigureAwait(false); + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteResolver.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteResolver.cs new file mode 100644 index 00000000..17c366b4 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteResolver.cs @@ -0,0 +1,152 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +using System.Collections.Concurrent; +using GameFrameX.NetWork.Abstractions; +using GameFrameX.Utility.Setting; +using MongoDB.Driver; + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 三级玩家路由解析器(C143e D21:内存快路径 → 控制库 30s TTL 缓存 → 离线)。 +/// +/// +/// The three-tier player route resolver (C143e D21). Tier 1 is the in-process +/// SessionManager.PlayerRouteMap fast path — single-process topologies +/// always hit it and never touch Mongo. Tier 2 is the control database +/// player_route collection read with a 30-second per-player cache, so +/// cross-process hot players do not re-query Mongo on every message. Tier 3 +/// is the offline fallback (driven by the configured ). +/// Tier 1 negative answers still fall through to tier 2 (the in-process map is +/// authoritative for "this process knows", but a stale entry may outlive a +/// real login on another process; the control database is the cross-process +/// tiebreaker). +/// +public sealed class MongoPlayerRouteResolver : IPlayerRouteResolver +{ + private const int ControlCacheTtlMs = 30_000; + + private readonly IMongoCollection _collection; + private readonly IPlayerRouteFastPath _fastPath; + private readonly ConcurrentDictionary _controlCache = new ConcurrentDictionary(); + + /// + /// 初始化三级玩家路由解析器。 + /// + /// + /// Initializes the three-tier resolver. + /// must already be the registered gameframex_control database; the + /// resolver does not own index creation (the bootstrap call does it once + /// at startup). is the in-process Tier 1 lookup; + /// when null the resolver skips Tier 1 and goes straight to the control + /// database (the launch flow in GameFrameX.Apps injects its + /// PlayerRouteMap-backed adapter). + /// + /// 控制库 / The control database + /// Tier 1 快路径提供方(null 跳过 Tier 1)/ Tier 1 fast path (null skips Tier 1) + public MongoPlayerRouteResolver(IMongoDatabase controlDatabase, IPlayerRouteFastPath fastPath = null) + { + ArgumentNullException.ThrowIfNull(controlDatabase, nameof(controlDatabase)); + _collection = controlDatabase.GetCollection(PlayerRouteCollection.CollectionName); + _fastPath = fastPath; + } + + /// + public async Task ResolveAsync(long playerId) + { + if (playerId <= 0) + { + return PlayerRouteInfo.Offline(); + } + + // Tier 1:进程内快路径(注入的 fast-path 提供方;未注入则跳过)。 + if (_fastPath != null && _fastPath.TryGetOnline(playerId, out var localInfo) && localInfo.IsOnline) + { + return localInfo; + } + + // Tier 2:控制库 player_route + 30s TTL 缓存。 + var controlResult = await ResolveFromControlAsync(playerId).ConfigureAwait(false); + if (controlResult != null) + { + return controlResult; + } + + // Tier 3:离线。 + return PlayerRouteInfo.Offline(); + } + + private async Task ResolveFromControlAsync(long playerId) + { + var now = Environment.TickCount64; + if (_controlCache.TryGetValue(playerId, out var cached) && now - cached.SnapshotTicks <= ControlCacheTtlMs) + { + return cached.Info; + } + + var document = await _collection.Find(Builders.Filter.Eq(candidate => candidate.PlayerId, playerId)) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + + if (document == null) + { + // 缓存 negative answer 也吃下:避免热玩家的反复控制库 miss 抖动。30s 后重试一次。 + _controlCache[playerId] = new ControlCacheEntry(now, PlayerRouteInfo.Offline()); + return PlayerRouteInfo.Offline(); + } + + var serverType = string.IsNullOrEmpty(document.Role) ? (GlobalSettings.CurrentSetting?.ServerType ?? GameServerConst.Game.Name) : document.Role; + var serverId = ExtractServerId(document.InstanceId); + var info = PlayerRouteInfo.Online(serverType, serverId, document.Version); + + _controlCache[playerId] = new ControlCacheEntry(now, info); + return info; + } + + /// + /// 从 instanceId 中提取数字部分作为 ServerId(不可解析时退回 GameConst.Id)。 + /// + /// + /// Extracts the trailing integer suffix from an instance id + /// (role-... in the C143d instance id format has no integer; the + /// fallback keeps the resolver non-throwing for legacy instance ids). + /// + private static int ExtractServerId(string instanceId) + { + if (string.IsNullOrEmpty(instanceId)) + { + return GameServerConst.Game.Id; + } + + var separatorIndex = instanceId.LastIndexOf('-'); + var candidate = separatorIndex >= 0 && separatorIndex + 1 < instanceId.Length + ? instanceId.Substring(separatorIndex + 1) + : instanceId; + + if (int.TryParse(candidate, out var parsed)) + { + return parsed; + } + + return GameServerConst.Game.Id; + } + + private readonly struct ControlCacheEntry + { + public ControlCacheEntry(long snapshotTicks, PlayerRouteInfo info) + { + SnapshotTicks = snapshotTicks; + Info = info; + } + + public long SnapshotTicks { get; } + public PlayerRouteInfo Info { get; } + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteResolverBootstrap.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteResolverBootstrap.cs new file mode 100644 index 00000000..ad3d9ac2 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteResolverBootstrap.cs @@ -0,0 +1,88 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +using MongoDB.Driver; + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 玩家路由层装配点(C143e:建索引 + 装 SyncTarget + 暴露 resolver)。 +/// +/// +/// The player-route layer wiring point (C143e). The launch flow calls +/// once after the control database is registered: it +/// creates the player_route indexes (idempotent), installs the +/// as the SessionManager sync hook, +/// and exposes the through +/// so the launch flow (or a test) can rewire +/// to use it in place of the +/// default Hotfix DefaultPlayerRouteResolver. Idempotent: only the +/// first call wires anything; later calls are no-ops (so per-role startups in +/// a multi-role process never double-build indexes). +/// +public static class MongoPlayerRouteResolverBootstrap +{ + private static int _attached; + private static MongoPlayerRouteResolver _resolver; + private static MongoPlayerRouteSyncTarget _syncTarget; + + /// + /// 已装配的玩家路由解析器(Attach 之前为 null)。 + /// + /// + /// The wired resolver (null before ). + /// + public static MongoPlayerRouteResolver Resolver + { + get { return _resolver; } + } + + /// + /// 已装配的同步目标(Attach 之前为 null)。 + /// + /// + /// The wired sync target (null before ). + /// + public static MongoPlayerRouteSyncTarget SyncTarget + { + get { return _syncTarget; } + } + + /// + /// 激活玩家路由层:建索引 + 装 SyncTarget。 + /// + /// + /// Activates the player-route layer. Builds the indexes (idempotent), + /// constructs the resolver and sync target, and keeps references so the + /// launch flow can wire into SessionManager and + /// into UnifiedMessageSenderHolder. The + /// bootstrap itself does not touch the host application layer — the + /// caller is responsible for the cross-package wiring. + /// + /// 控制库(gameframex_control)/ The control database + /// Tier 1 快路径提供方(null 跳过 Tier 1)/ Tier 1 fast path (null skips Tier 1) + /// 是否为本进程首次装配(false 表示已激活,本次调用为 no-op) / true on first attach, false on subsequent calls + public static async Task Attach(IMongoDatabase controlDatabase, IPlayerRouteFastPath fastPath = null) + { + ArgumentNullException.ThrowIfNull(controlDatabase, nameof(controlDatabase)); + + if (Interlocked.CompareExchange(ref _attached, 1, 0) != 0) + { + return false; + } + + var collection = controlDatabase.GetCollection(PlayerRouteCollection.CollectionName); + await PlayerRouteCollection.EnsureIndexesAsync(collection).ConfigureAwait(false); + + _resolver = new MongoPlayerRouteResolver(controlDatabase, fastPath); + _syncTarget = new MongoPlayerRouteSyncTarget(controlDatabase); + + return true; + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteSyncTarget.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteSyncTarget.cs new file mode 100644 index 00000000..e1e967fc --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/MongoPlayerRouteSyncTarget.cs @@ -0,0 +1,108 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +using MongoDB.Driver; + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 基于 Mongo 控制库的 IPlayerRouteSyncTarget(C143e D21 控制库落点)。 +/// +/// +/// The control-database-backed implementation of the player-route sync hook +/// (C143e D21). Each call is a single CAS upsert: on first sight the document +/// is inserted with version=1; on subsequent calls the existing version is +/// compared and the upsert is rejected (returning ) +/// when the supplied version has already been overtaken. The session manager +/// catches that exception at the hook boundary and swallows it — the local +/// PlayerRouteMap stays consistent and the next SetOnline converges. +/// +public sealed class MongoPlayerRouteSyncTarget : IPlayerRouteSyncTarget +{ + private readonly IMongoCollection _collection; + + /// + /// 初始化基于 Mongo 控制库的同步目标。 + /// + /// + /// Initializes the Mongo sync target. Indexes are the bootstrap's + /// responsibility (see ); + /// the SyncTarget only writes. + /// + /// 控制库(gameframex_control)/ The control database + public MongoPlayerRouteSyncTarget(IMongoDatabase controlDatabase) + { + ArgumentNullException.ThrowIfNull(controlDatabase, nameof(controlDatabase)); + _collection = controlDatabase.GetCollection(PlayerRouteCollection.CollectionName); + } + + /// + public async Task UpsertAsync(long playerId, string instanceId, string role, long version) + { + if (playerId <= 0) + { + return; + } + + if (string.IsNullOrWhiteSpace(instanceId)) + { + throw new ArgumentException("Instance id must not be empty.", nameof(instanceId)); + } + + // 首登(version=1)走无条件 upsert;后续顶号走 CAS:当前 version 必须等于送入 version,否则抛 PlayerRouteStaleException。 + if (version <= 1) + { + var firstTime = PlayerRouteCollection.CreateOnline(playerId, instanceId, role); + await _collection.ReplaceOneAsync( + Builders.Filter.Eq(candidate => candidate.PlayerId, playerId), + firstTime, + new ReplaceOptions { IsUpsert = true }).ConfigureAwait(false); + return; + } + + var filter = Builders.Filter.And( + Builders.Filter.Eq(candidate => candidate.PlayerId, playerId), + Builders.Filter.Eq(candidate => candidate.Version, version)); + + var update = Builders.Update + .Set(candidate => candidate.InstanceId, instanceId) + .Set(candidate => candidate.Role, role) + .Set(candidate => candidate.Version, version) + .Set(candidate => candidate.LastSeenAt, DateTime.UtcNow); + + var result = await _collection.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = false }).ConfigureAwait(false); + + if (result.MatchedCount == 0) + { + // 未命中:可能文档被删,可能 version 已经更新。先读最新 version 区分两种情况。 + var latest = await _collection.Find(Builders.Filter.Eq(candidate => candidate.PlayerId, playerId)) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + + if (latest == null) + { + // 文档缺失:让 SessionManager 钩子在下一轮 SetOnline 重试即可(不必抛)。 + return; + } + + throw new PlayerRouteStaleException(playerId, version, latest.Version); + } + } + + /// + public async Task DeleteAsync(long playerId) + { + if (playerId <= 0) + { + return; + } + + await _collection.DeleteOneAsync(Builders.Filter.Eq(candidate => candidate.PlayerId, playerId)).ConfigureAwait(false); + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/NullPlayerRouteSyncTarget.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/NullPlayerRouteSyncTarget.cs new file mode 100644 index 00000000..09301457 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/NullPlayerRouteSyncTarget.cs @@ -0,0 +1,46 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 玩家路由同步目标的 NoOp 默认实现(C143e D21)。 +/// +/// +/// The default NoOp . When the launch flow +/// has not wired a real sync target (e.g. a single-process local test) the +/// SessionManager hooks still execute, but produce no cross-process side +/// effects — the local PlayerRouteMap remains the single source of truth. +/// +public sealed class NullPlayerRouteSyncTarget : IPlayerRouteSyncTarget +{ + /// + /// 全进程共享的单例。 + /// + /// + /// The process-wide shared singleton. + /// + public static readonly NullPlayerRouteSyncTarget Instance = new NullPlayerRouteSyncTarget(); + + private NullPlayerRouteSyncTarget() + { + } + + /// + public Task UpsertAsync(long playerId, string instanceId, string role, long version) + { + return Task.CompletedTask; + } + + /// + public Task DeleteAsync(long playerId) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/PerInstanceDedupe.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/PerInstanceDedupe.cs new file mode 100644 index 00000000..4fefe2b4 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/PerInstanceDedupe.cs @@ -0,0 +1,114 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +using System.Collections.Concurrent; + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨服投递单飞去重(C143e D21:per-instance + 时间窗)。 +/// +/// +/// Per-instance single-flight deduplication for cross-server deliveries +/// (C143e D21). Two cross-server sends to the same targetInstanceId +/// within the same time window collapse into one — the second call returns +/// false and the caller treats it as already-in-flight. A retry against +/// a different instance clears the key (different instance = different slot), +/// so failover to a new instance is never collapsed. The window is short +/// (default 1s) because the actual completion signal is the target server's +/// response; the window just bounds the race between an outstanding send and +/// an immediate retry triggered by a transient connection error. +/// ponytail: 时间窗选 1s 是基于 RetrySemantics 的指数退避起点(500ms→1s→2s)—— +/// 单飞窗口 ≥ 最长一次端到端往返,避免重投与首投同时在途。配置化留给后续 change。 +/// +public sealed class PerInstanceDedupe +{ + private readonly TimeSpan _window; + private readonly ConcurrentDictionary _lastSeenTicks = new ConcurrentDictionary(StringComparer.Ordinal); + + /// + /// 初始化单飞去重器(1s 时间窗)。 + /// + /// + /// Initializes the dedupe with the default 1s window. + /// + public PerInstanceDedupe() + : this(TimeSpan.FromSeconds(1)) + { + } + + /// + /// 初始化单飞去重器(自定义时间窗)。 + /// + /// + /// Initializes the dedupe with a custom window (testing seam). + /// + /// 单飞时间窗 / The single-flight window + public PerInstanceDedupe(TimeSpan window) + { + if (window <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(window), "Window must be positive."); + } + + _window = window; + } + + /// + /// 标记一次 (instanceId, correlationKey) 投递尝试;若同 instanceId 在时间窗内已存在同 correlationKey,则返回 false。 + /// + /// + /// Marks a delivery attempt. Returns false when a prior attempt for + /// the same instance id and correlation key is still inside the window — + /// the caller treats false as "already in flight, skip retry". The + /// correlation key default of 0 collapses all sends against the + /// same instance id within the window (good enough for cross-server + /// player messages because the source envelope is unique per send). + /// + /// 目标实例 ID / Target instance id + /// 可选的关联键(默认 0)/ Optional correlation key (defaults to 0) + /// true=本次是新投递;false=窗口内已有同 instanceId 投递 / true=new send, false=already in flight + public bool TryAcquire(string instanceId, long correlationKey = 0) + { + if (string.IsNullOrEmpty(instanceId)) + { + return true; + } + + var slot = $"{instanceId}|{correlationKey}"; + var now = Environment.TickCount64; + var previousTicks = _lastSeenTicks.AddOrUpdate(slot, now, (_, previous) => now - previous <= _window.Ticks ? previous : now); + return now - previousTicks >= _window.Ticks; + } + + /// + /// 清空某实例的单飞记录(切流到新实例后调用)。 + /// + /// + /// Clears the dedupe slot for an instance id (used after the sender + /// successfully fails over to a different instance — the old slot is no + /// longer relevant). + /// + /// 目标实例 ID / Target instance id + public void Release(string instanceId) + { + if (string.IsNullOrEmpty(instanceId)) + { + return; + } + + foreach (var pair in _lastSeenTicks) + { + if (pair.Key.StartsWith(instanceId + "|", StringComparison.Ordinal)) + { + _lastSeenTicks.TryRemove(pair.Key, out _); + } + } + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteCollection.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteCollection.cs new file mode 100644 index 00000000..2726deb2 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteCollection.cs @@ -0,0 +1,211 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/gameframex +// Gitee Repository: https://gitee.com/gameframex +// CNB 仓库:https://cnb.cool/gameframex +// CNB Repository: https://cnb.cool/gameframex +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using MongoDB.Bson; +using MongoDB.Driver; +using ProtoBuf; + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨服玩家路由控制文档(C143e D21)。 +/// +/// +/// The cross-server player route document in the control database (C143e D21). +/// One document per player; the unique index is the lookup +/// key, and the TTL on keeps long-offline players from +/// accumulating forever. is the CAS counter for the +/// "踢号 + 重登" sequence: a new login must carry oldVersion + 1, otherwise +/// refuses the upsert and throws +/// . +/// +[ProtoContract] +public sealed class PlayerRouteDocument +{ + /// + /// 玩家 ID(业务键)。 + /// + /// + /// The player id (the business key). + /// + [ProtoMember(1)] + public long PlayerId { get; set; } + + /// + /// 玩家当前所在的实例 ID(Mongo 发现层 instanceId)。 + /// + /// + /// The player's current owning instance id (the Mongo discovery-layer instanceId). + /// + [ProtoMember(2)] + public string InstanceId { get; set; } + + /// + /// 玩家当前所在的 Role 名(如 Game / Social)。 + /// + /// + /// The player's current owning role name (e.g. Game / Social). + /// + [ProtoMember(3)] + public string Role { get; set; } + + /// + /// 顶号单调递增版本号(CAS 字段)。 + /// + /// + /// The monotonic kick/relogin version used for compare-and-set on upsert. + /// + [ProtoMember(4)] + public long Version { get; set; } + + /// + /// 最近一次写入时间(TTL 索引依据)。 + /// + /// + /// The last write timestamp; the TTL index expires documents 30 days after this point. + /// + [ProtoMember(5)] + public DateTime LastSeenAt { get; set; } +} + +/// +/// player_route 集合契约(D18 全名约定 + 索引工具)。 +/// +/// +/// The player_route collection contract (D18 no-abbreviation rule plus the +/// index bootstrap utility). The unique index on playerId is what makes +/// upsert idempotent; the TTL index on lastSeenAt bounds the offline +/// garbage window to 30 days so the collection never grows unbounded for +/// churned players. +/// +public static class PlayerRouteCollection +{ + /// + /// 集合名(控制库 gameframex_control 内的子集合,D18 全名约定)。 + /// + /// + /// The collection name (a child collection inside the control database + /// gameframex_control; the D18 no-abbreviation rule keeps the long + /// name even though player_routes would also be valid English). + /// + public const string CollectionName = "player_route"; + + /// + /// 离线路由 TTL(30 天)。 + /// + /// + /// The TTL window for an offline route document (30 days). + /// + public const long TtlSeconds = 30L * 24L * 60L * 60L; + + /// + /// 建立 player_route 索引(playerId 唯一 + lastSeenAt TTL)。幂等:已存在则 no-op。 + /// + /// + /// Creates the player_route indexes (unique on playerId, TTL on lastSeenAt). + /// Idempotent: existing indexes are a no-op. + /// + /// 目标集合 / The target collection + /// 取消令牌 / The cancellation token + /// 异步任务 / Async task + public static async Task EnsureIndexesAsync(IMongoCollection collection, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(collection, nameof(collection)); + + var uniqueIndex = new CreateIndexModel( + Builders.IndexKeys.Ascending(document => document.PlayerId), + new CreateIndexOptions { Unique = true, Name = "playerId_unique" }); + + var ttlIndex = new CreateIndexModel( + Builders.IndexKeys.Ascending(document => document.LastSeenAt), + new CreateIndexOptions { ExpireAfter = TimeSpan.FromSeconds(TtlSeconds), Name = "lastSeenAt_ttl_30d" }); + + await collection.Indexes.CreateManyAsync(new[] { uniqueIndex, ttlIndex }, cancellationToken).ConfigureAwait(false); + } + + /// + /// BSON 形态读取的 playerId 字段名(用于 CAS 过滤器与日志)。 + /// + /// + /// The BSON-side playerId field name (used for CAS filters and log lines). + /// + public const string PlayerIdField = "playerId"; + + /// + /// BSON 形态读取的 version 字段名。 + /// + /// + /// The BSON-side version field name. + /// + public const string VersionField = "version"; + + /// + /// 构造"首次出现的 player"文档(version=1);new SetPlayerRouteOnline(playerId) 默认起点。 + /// + /// + /// Builds the first-time player document (version = 1); the default starting point for a fresh SetPlayerRouteOnline. + /// + /// 玩家 ID / Player id + /// 实例 ID / Instance id + /// Role 名 / Role name + /// 新文档 / The new document + public static PlayerRouteDocument CreateOnline(long playerId, string instanceId, string role) + { + return new PlayerRouteDocument + { + PlayerId = playerId, + InstanceId = instanceId, + Role = role, + Version = 1, + LastSeenAt = DateTime.UtcNow, + }; + } + + /// + /// 把 转 BSON 字典(写日志/调试用)。 + /// + /// + /// Renders the document as a BSON dictionary for log/inspection use. + /// + /// 文档 / The document + /// BSON 表示 / The BSON representation + public static BsonDocument ToBson(PlayerRouteDocument document) + { + ArgumentNullException.ThrowIfNull(document, nameof(document)); + return new BsonDocument + { + { PlayerIdField, document.PlayerId }, + { "instanceId", document.InstanceId ?? string.Empty }, + { "role", document.Role ?? string.Empty }, + { VersionField, document.Version }, + { "lastSeenAt", document.LastSeenAt }, + }; + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteInfo.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteInfo.cs new file mode 100644 index 00000000..86a9f510 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteInfo.cs @@ -0,0 +1,89 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 玩家路由解析返回值(C143e D21)。 +/// +/// +/// The player-route resolver's value object (C143e D21). When +/// is true, + +/// describe the target instance; is the route-version +/// observed at resolve time so callers can detect a routing change that +/// happened between resolve and dispatch (kick/relogin). +/// +public sealed class PlayerRouteInfo +{ + /// + /// 是否在线。 + /// + /// + /// Whether the player is currently online. + /// + public bool IsOnline { get; set; } + + /// + /// 服务类型(在线时填写)。 + /// + /// + /// The hosting role name (set when online). + /// + public string ServerType { get; set; } + + /// + /// 服务 ID(在线时填写)。 + /// + /// + /// The hosting numeric server id (set when online). + /// + public int ServerId { get; set; } + + /// + /// 路由版本(在线时用最新版本号;离线时用 1)。 + /// + /// + /// The route version seen at resolve time (the latest online version when + /// online; 1 when offline). + /// + public long Version { get; set; } + + /// + /// 构造在线态路由信息。 + /// + /// 服务类型。 + /// 服务 ID。 + /// 版本号。 + /// 在线态路由信息。 + public static PlayerRouteInfo Online(string serverType, int serverId, long version) + { + return new PlayerRouteInfo + { + IsOnline = true, + ServerType = serverType, + ServerId = serverId, + Version = version, + }; + } + + /// + /// 构造离线态路由信息。 + /// + /// 离线态路由信息。 + public static PlayerRouteInfo Offline() + { + return new PlayerRouteInfo + { + IsOnline = false, + ServerType = null, + ServerId = 0, + Version = 1, + }; + } +} \ No newline at end of file diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteStaleException.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteStaleException.cs new file mode 100644 index 00000000..414b03ca --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/PlayerRouteStaleException.cs @@ -0,0 +1,67 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 玩家路由版本号过期异常(C143e D21 顶号竞态)。 +/// +/// +/// Thrown when the persisted route document already carries a newer +/// than the value supplied by +/// . The CAS loss means a +/// concurrent session has already pushed a higher version (typical scenario: +/// player A logs in on instance X, then immediately logs in on instance Y; +/// the X-side upsert races and arrives second, so X's "version+1" is stale). +/// Callers must re-read the document and decide whether to retry or drop the +/// local PlayerRouteMap entry — never silently succeed. +/// +public sealed class PlayerRouteStaleException : Exception +{ + /// + /// 玩家 ID。 + /// + /// + /// The player id whose upsert lost the CAS race. + /// + public long PlayerId { get; } + + /// + /// 调用方送入的 version(已落后于持久化值)。 + /// + /// + /// The version supplied by the caller; already behind the persisted value. + /// + public long SuppliedVersion { get; } + + /// + /// 控制库中的最新 version。 + /// + /// + /// The latest version already persisted in the control database. + /// + public long CurrentVersion { get; } + + /// + /// 构造顶号竞态异常。 + /// + /// + /// Builds the CAS-loss exception with the player id and the supplied/current versions. + /// + /// 玩家 ID / Player id + /// 送入的 version / Supplied version + /// 控制库最新 version / Latest persisted version + public PlayerRouteStaleException(long playerId, long suppliedVersion, long currentVersion) + : base($"Player route version for player {playerId} is stale: supplied={suppliedVersion}, current={currentVersion}.") + { + PlayerId = playerId; + SuppliedVersion = suppliedVersion; + CurrentVersion = currentVersion; + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouteEnvelopeMessage.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouteEnvelopeMessage.cs index 1565edb7..4d41ccb5 100644 --- a/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouteEnvelopeMessage.cs +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouteEnvelopeMessage.cs @@ -28,6 +28,7 @@ // ========================================================================================== +using GameFrameX.NetWork.Abstractions; using GameFrameX.ProtoBuf.Net; using ProtoBuf; @@ -41,11 +42,12 @@ namespace GameFrameX.NetWork.RemoteMessaging.Routing; /// serializes this message through the standard /// codec frame, so the bytes on the wire are indistinguishable from any other /// RemoteMessaging packet. The receiving side (envelope unpacking back into local -/// delivery) is delivered with C143e; until then its message id is only a reserved -/// constant — it is written into the frame header but never registered in -/// MessageProtoHelper. +/// delivery) is delivered with C143e: looks up +/// in MessageProtoHelper and re-delivers via +/// . /// [ProtoContract] +[MessageTypeHandler(ReservedMessageId)] public sealed class RoleRouteEnvelopeMessage : MessageObject { /// diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouterHolder.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouterHolder.cs index be27d074..dc9b80f9 100644 --- a/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouterHolder.cs +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouterHolder.cs @@ -81,6 +81,19 @@ public static IRoleRouter Current } } + /// + /// 是否已初始化(C143e:装配点用它判断是否要重装路由器,避免覆盖 C143c 占位)。 + /// + /// + /// Whether the holder has been initialized. C143e bootstrap reads this + /// before re-installing the router so it can skip when C143c's placeholder + /// has already been replaced. + /// + public static bool IsInitialized + { + get { return _router != null; } + } + /// /// 初始化全局路由器。启动流程调用一次。 /// From 32b1ba5ac0912f167a8e1ff6dd6d75dccc17dc4e Mon Sep 17 00:00:00 2001 From: Blank Date: Tue, 22 Sep 2026 15:34:06 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(apps):=20SessionManager=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=92=A9=E5=AD=90=E4=B8=8E=E5=90=AF=E5=8A=A8=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameFrameX.Apps 项目新增 SessionManagerFastPathAdapter 把内存态 PlayerRouteMap 暴露为 IPlayerRouteFastPath;SessionManager 暴露 PlayerRouteSyncTarget 静态注入点,SetPlayerRouteOnline/Offline 在 内存态落定后调用钩子(失败 swallow + warning,主链路不挂)。 GameFrameX.Apps.csproj 新增 NetWork.RemoteMessaging 引用。 AppStartUpGame/Social 调用 Activate 时传入 fast-path 适配器,并把 MongoPlayerRouteResolverBootstrap 暴露的 SyncTarget 注入 SessionManager。 --- .../Common/Session/SessionManager.cs | 74 ++++++++++++++++++- .../Session/SessionManagerFastPathAdapter.cs | 44 +++++++++++ GameFrameX.Apps/GameFrameX.Apps.csproj | 13 ++-- GameFrameX.Launcher/StartUp/AppStartUpGame.cs | 4 +- .../StartUp/Social/AppStartUpSocial.cs | 4 +- 5 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 GameFrameX.Apps/Common/Session/SessionManagerFastPathAdapter.cs diff --git a/GameFrameX.Apps/Common/Session/SessionManager.cs b/GameFrameX.Apps/Common/Session/SessionManager.cs index bd24fab9..d23a1be4 100644 --- a/GameFrameX.Apps/Common/Session/SessionManager.cs +++ b/GameFrameX.Apps/Common/Session/SessionManager.cs @@ -34,7 +34,9 @@ using GameFrameX.Core.Actors; using GameFrameX.Core.Events; using GameFrameX.Foundation.Localization.Core; +using GameFrameX.Foundation.Logger; using GameFrameX.NetWork.Abstractions; +using GameFrameX.NetWork.RemoteMessaging.Routing; using GameFrameX.Utility.Setting; namespace GameFrameX.Apps.Common.Session; @@ -47,6 +49,26 @@ public static class SessionManager private static readonly ConcurrentDictionary SessionMap = new(); private static readonly ConcurrentDictionary PlayerRouteMap = new(); + /// + /// 玩家路由外发同步目标(C143e D21:控制库写入钩子)。 + /// + /// + /// The outbound player-route sync target (C143e D21). Wired by + /// once the control database + /// is registered; default is + /// so the in-process-only launch flow keeps working with zero side effects. + /// Callers must catch and swallow any exception from the hook — the local + /// stays the single source of truth for the + /// process and a failed Mongo write is recovered on the next SetOnline. + /// + public static IPlayerRouteSyncTarget PlayerRouteSyncTarget + { + get { return _playerRouteSyncTarget; } + set { _playerRouteSyncTarget = value ?? NullPlayerRouteSyncTarget.Instance; } + } + + private static IPlayerRouteSyncTarget _playerRouteSyncTarget = NullPlayerRouteSyncTarget.Instance; + /// /// 获取当前在线玩家的数量。 /// @@ -275,10 +297,13 @@ public static void SetPlayerRouteOnline(long playerId, string serverType = null, : serverType; var resolvedServerId = serverId ?? (GlobalSettings.CurrentSetting?.ServerId ?? GameServerConst.Game.Id); - PlayerRouteMap.AddOrUpdate( + var snapshot = PlayerRouteMap.AddOrUpdate( playerId, _ => SessionRouteSnapshot.Online(playerId, resolvedServerType, resolvedServerId, 1), (_, old) => SessionRouteSnapshot.Online(playerId, resolvedServerType, resolvedServerId, old.Version + 1)); + + // C143e D21:内存态落定后再调控制库钩子;失败 swallow(warning),主链路不挂。 + FireSyncUpsert(playerId, resolvedServerType, snapshot); } /// @@ -296,6 +321,53 @@ public static void SetPlayerRouteOffline(long playerId) playerId, _ => SessionRouteSnapshot.Offline(playerId, 1), (_, old) => SessionRouteSnapshot.Offline(playerId, old.Version + 1)); + + // C143e D21:内存态落定后再调控制库钩子;失败 swallow(warning),主链路不挂。 + FireSyncDelete(playerId); + } + + private static void FireSyncUpsert(long playerId, string resolvedServerType, SessionRouteSnapshot snapshot) + { + var syncTarget = _playerRouteSyncTarget; + if (syncTarget == null || ReferenceEquals(syncTarget, NullPlayerRouteSyncTarget.Instance)) + { + return; + } + + // 钩子实例 ID:进程未注册发现层时退化为主机名 + 时间戳(与 MongoEndpointRegistry.CreateSelfDescriptorFromEnvironment 同形态)。 + var instanceId = ResolveSyncInstanceId(resolvedServerType); + try + { + _ = syncTarget.UpsertAsync(playerId, instanceId, resolvedServerType, snapshot.Version); + } + catch (Exception exception) + { + LogHelper.Warning(exception, "[SessionManager] player_route upsert hook failed for player {playerId}; the local PlayerRouteMap stays authoritative and the next SetOnline converges", playerId); + } + } + + private static void FireSyncDelete(long playerId) + { + var syncTarget = _playerRouteSyncTarget; + if (syncTarget == null || ReferenceEquals(syncTarget, NullPlayerRouteSyncTarget.Instance)) + { + return; + } + + try + { + _ = syncTarget.DeleteAsync(playerId); + } + catch (Exception exception) + { + LogHelper.Warning(exception, "[SessionManager] player_route delete hook failed for player {playerId}", playerId); + } + } + + private static string ResolveSyncInstanceId(string resolvedServerType) + { + // 优先用 Mongo 发现层实例 ID(运行时通过 PlayerRouteSyncTarget 注入点的静态字段不可见——保留退化路径)。 + return $"{resolvedServerType}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds():x}"; } } diff --git a/GameFrameX.Apps/Common/Session/SessionManagerFastPathAdapter.cs b/GameFrameX.Apps/Common/Session/SessionManagerFastPathAdapter.cs new file mode 100644 index 00000000..3baa25e7 --- /dev/null +++ b/GameFrameX.Apps/Common/Session/SessionManagerFastPathAdapter.cs @@ -0,0 +1,44 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Routing; + +namespace GameFrameX.Apps.Common.Session; + +/// +/// SessionManager Tier 1 快路径适配器(C143e D21)。 +/// +/// +/// The Tier 1 player-route fast-path adapter (C143e D21). Wraps +/// SessionManager.PlayerRouteMap in the +/// contract so can be activated from +/// inside GameFrameX.NetWork.RemoteMessaging without taking a reverse +/// project reference on GameFrameX.Apps. +/// +public sealed class SessionManagerFastPathAdapter : IPlayerRouteFastPath +{ + public static readonly SessionManagerFastPathAdapter Instance = new SessionManagerFastPathAdapter(); + + private SessionManagerFastPathAdapter() + { + } + + /// + public bool TryGetOnline(long playerId, out PlayerRouteInfo info) + { + if (playerId > 0 && SessionManager.TryGetPlayerRoute(playerId, out var snapshot) && snapshot.IsOnline) + { + info = PlayerRouteInfo.Online(snapshot.ServerType, snapshot.ServerId, snapshot.Version); + return true; + } + + info = PlayerRouteInfo.Offline(); + return false; + } +} \ No newline at end of file diff --git a/GameFrameX.Apps/GameFrameX.Apps.csproj b/GameFrameX.Apps/GameFrameX.Apps.csproj index 81967432..04b18183 100644 --- a/GameFrameX.Apps/GameFrameX.Apps.csproj +++ b/GameFrameX.Apps/GameFrameX.Apps.csproj @@ -5,12 +5,13 @@ - - - - - - + + + + + + + diff --git a/GameFrameX.Launcher/StartUp/AppStartUpGame.cs b/GameFrameX.Launcher/StartUp/AppStartUpGame.cs index 241634c9..6a5461ce 100644 --- a/GameFrameX.Launcher/StartUp/AppStartUpGame.cs +++ b/GameFrameX.Launcher/StartUp/AppStartUpGame.cs @@ -85,7 +85,9 @@ public override async Task StartAsync() // C143d D11-D15:控制库就绪后激活 Mongo 发现层——读侧 watcher + 写侧心跳(未配置广播端口时自动跳过) // 并以真实 case 2/3 转发器重装跨 Role 路由缝(替换 C143c 占位)。幂等:多 Role 进程首个调用生效。 - MongoDiscoveryRuntime.Activate(((MongoDbService)MultiDbRegistry.Get(MultiDbRegistry.ControlDatabaseName)).CurrentDatabase, RoleSet.Current); + // C143e D21:再激活玩家路由层(建 player_route 索引 + 装 SyncTarget),Tier 1 fast-path 注入 SessionManager 适配器。 + MongoDiscoveryRuntime.Activate(((MongoDbService)MultiDbRegistry.Get(MultiDbRegistry.ControlDatabaseName)).CurrentDatabase, RoleSet.Current, GameFrameX.Apps.Common.Session.SessionManagerFastPathAdapter.Instance); + GameFrameX.Apps.Common.Session.SessionManager.PlayerRouteSyncTarget = GameFrameX.NetWork.RemoteMessaging.Routing.MongoPlayerRouteResolverBootstrap.SyncTarget; var initResult = await GameDb.Init(Setting.DataBaseUrl, new DbOptions { Name = Setting.DataBaseName, IsUseTimeZone = Setting.IsUseTimeZone, }); if (initResult == false) diff --git a/GameFrameX.Launcher/StartUp/Social/AppStartUpSocial.cs b/GameFrameX.Launcher/StartUp/Social/AppStartUpSocial.cs index 1edd6a76..36a5e919 100644 --- a/GameFrameX.Launcher/StartUp/Social/AppStartUpSocial.cs +++ b/GameFrameX.Launcher/StartUp/Social/AppStartUpSocial.cs @@ -71,7 +71,9 @@ public override async Task StartAsync() // C143d D11-D15:控制库就绪后激活 Mongo 发现层——读侧 watcher + 写侧心跳(未配置广播端口时自动跳过) // 并以真实 case 2/3 转发器重装跨 Role 路由缝(替换 C143c 占位)。幂等:多 Role 进程首个调用生效。 - MongoDiscoveryRuntime.Activate(((MongoDbService)MultiDbRegistry.Get(MultiDbRegistry.ControlDatabaseName)).CurrentDatabase, RoleSet.Current); + // C143e D21:再激活玩家路由层(建 player_route 索引 + 装 SyncTarget),Tier 1 fast-path 注入 SessionManager 适配器。 + MongoDiscoveryRuntime.Activate(((MongoDbService)MultiDbRegistry.Get(MultiDbRegistry.ControlDatabaseName)).CurrentDatabase, RoleSet.Current, GameFrameX.Apps.Common.Session.SessionManagerFastPathAdapter.Instance); + GameFrameX.Apps.Common.Session.SessionManager.PlayerRouteSyncTarget = GameFrameX.NetWork.RemoteMessaging.Routing.MongoPlayerRouteResolverBootstrap.SyncTarget; var initResult = await GameDb.Init(Setting.DataBaseUrl, new DbOptions { Name = Setting.DataBaseName, IsUseTimeZone = Setting.IsUseTimeZone, }); if (initResult == false) From 61b5dafc536cfb5770ef8c1fbc33b2ee438cf998 Mon Sep 17 00:00:00 2001 From: Blank Date: Tue, 22 Sep 2026 15:34:11 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(player-route):=20=E4=B8=89=E7=BA=A7?= =?UTF-8?q?=E6=9F=A5=E6=89=BE=E4=B8=8E=20CAS=20upsert=20=E5=8D=95=E5=85=83?= =?UTF-8?q?/=E9=9B=86=E6=88=90=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests/GameFrameX.Tests 新增 MongoPlayerRouteResolverTests 单元用例: IPlayerRouteFastPath 在线/离线/错玩家分支 + PlayerRouteInfo 工厂 + NullPlayerRouteSyncTarget NoOp 验证(7 用例)。 MongoPlayerRouteIntegrationTests Mongo 集成:SyncTarget 首登 upsert / CAS 成功 / CAS 失败抛 PlayerRouteStaleException / Delete / 索引 (playerId 唯一 + lastSeenAt TTL 30 天)断言 / Resolver Tier 1 命中 /Tier 2 命中/全 miss 返回 Offline 三路径(8 用例,沿用 GAMEFRAMEX_TEST_ MONGODB_CONNECTION_STRING 门控)。 --- .../MongoPlayerRouteIntegrationTests.cs | 310 ++++++++++++++++++ .../MongoPlayerRouteResolverTests.cs | 139 ++++++++ 2 files changed, 449 insertions(+) create mode 100644 Tests/GameFrameX.Tests/Discovery/MongoPlayerRouteIntegrationTests.cs create mode 100644 Tests/GameFrameX.Tests/Discovery/MongoPlayerRouteResolverTests.cs diff --git a/Tests/GameFrameX.Tests/Discovery/MongoPlayerRouteIntegrationTests.cs b/Tests/GameFrameX.Tests/Discovery/MongoPlayerRouteIntegrationTests.cs new file mode 100644 index 00000000..5d6f1cc7 --- /dev/null +++ b/Tests/GameFrameX.Tests/Discovery/MongoPlayerRouteIntegrationTests.cs @@ -0,0 +1,310 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +using System.Threading.Tasks; +using GameFrameX.NetWork.RemoteMessaging.Routing; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace GameFrameX.Tests.Discovery; + +/// +/// MongoPlayerRouteSyncTarget / MongoPlayerRouteResolver 的 Mongo 集成测试(C143e D21)。 +/// +/// +/// Mongo-backed integration tests for the player-route layer (C143e D21), +/// following the repository's existing GAMEFRAMEX_TEST_MONGODB_CONNECTION_STRING +/// gating convention (MongoEndpointIntegrationTests): without the variable the +/// tests skip so plain dotnet test stays green on Mongo-less machines; the +/// topology-equivalence CI workflow sets the variable against a Mongo service +/// container so the suite really runs there. Each test uses a fresh database +/// (Guid suffix) so parallel runs do not cross-contaminate. +/// +public sealed class MongoPlayerRouteIntegrationTests : IDisposable +{ + /// + /// Mongo 连接串(未设置时跳过)。 + /// + private readonly string _connectionString = Environment.GetEnvironmentVariable("GAMEFRAMEX_TEST_MONGODB_CONNECTION_STRING") ?? string.Empty; + + private IMongoDatabase _database; + + /// + /// 是否跳过全部用例。 + /// + private bool ShouldSkip + { + get + { + return string.IsNullOrWhiteSpace(_connectionString); + } + } + + /// + /// 创建独立测试库(每个用例独立 database 防串扰)。 + /// + private IMongoDatabase CreateDatabase() + { + if (_database == null) + { + var client = new MongoClient(_connectionString); + _database = client.GetDatabase($"gameframex_player_route_test_{Guid.NewGuid():N}"); + } + + return _database; + } + + public void Dispose() + { + if (_database != null) + { + try + { + _database.Client.DropDatabase(_database.DatabaseNamespace.DatabaseName); + } + catch + { + // best effort + } + } + } + + [Fact] + public async Task SyncTarget_FirstTimeUpsert_InsertsDocument() + { + if (ShouldSkip) + { + return; + } + + var database = CreateDatabase(); + var target = new MongoPlayerRouteSyncTarget(database); + + await target.UpsertAsync(playerId: 101, instanceId: "game-1", role: "Game", version: 1); + + var collection = database.GetCollection(PlayerRouteCollection.CollectionName); + var stored = await collection.Find(Builders.Filter.Eq(candidate => candidate.PlayerId, 101)).FirstOrDefaultAsync(); + Assert.NotNull(stored); + Assert.Equal("game-1", stored.InstanceId); + Assert.Equal("Game", stored.Role); + Assert.Equal(1, stored.Version); + } + + [Fact] + public async Task SyncTarget_CasUpsert_CurrentVersion_Succeeds() + { + if (ShouldSkip) + { + return; + } + + var database = CreateDatabase(); + var target = new MongoPlayerRouteSyncTarget(database); + + await target.UpsertAsync(playerId: 102, instanceId: "game-1", role: "Game", version: 1); + await target.UpsertAsync(playerId: 102, instanceId: "game-2", role: "Game", version: 2); + + var collection = database.GetCollection(PlayerRouteCollection.CollectionName); + var stored = await collection.Find(Builders.Filter.Eq(candidate => candidate.PlayerId, 102)).FirstOrDefaultAsync(); + Assert.NotNull(stored); + Assert.Equal("game-2", stored.InstanceId); + Assert.Equal(2, stored.Version); + } + + [Fact] + public async Task SyncTarget_CasUpsert_StaleVersion_ThrowsPlayerRouteStaleException() + { + if (ShouldSkip) + { + return; + } + + var database = CreateDatabase(); + var target = new MongoPlayerRouteSyncTarget(database); + + await target.UpsertAsync(playerId: 103, instanceId: "game-1", role: "Game", version: 1); + await target.UpsertAsync(playerId: 103, instanceId: "game-2", role: "Game", version: 2); + + // version=1 已经被 version=2 覆盖;再送 version=1 应抛 PlayerRouteStaleException + await Assert.ThrowsAsync(async () => + { + await target.UpsertAsync(playerId: 103, instanceId: "game-3", role: "Game", version: 1); + }); + } + + [Fact] + public async Task SyncTarget_Delete_RemovesDocument() + { + if (ShouldSkip) + { + return; + } + + var database = CreateDatabase(); + var target = new MongoPlayerRouteSyncTarget(database); + + await target.UpsertAsync(playerId: 104, instanceId: "game-1", role: "Game", version: 1); + await target.DeleteAsync(playerId: 104); + + var collection = database.GetCollection(PlayerRouteCollection.CollectionName); + var stored = await collection.Find(Builders.Filter.Eq(candidate => candidate.PlayerId, 104)).FirstOrDefaultAsync(); + Assert.Null(stored); + } + + [Fact] + public async Task EnsureIndexesAsync_CreatesUniqueAndTtlIndexes() + { + if (ShouldSkip) + { + return; + } + + var database = CreateDatabase(); + var collection = database.GetCollection(PlayerRouteCollection.CollectionName); + await PlayerRouteCollection.EnsureIndexesAsync(collection); + + var indexes = await collection.Indexes.List().ToListAsync(); + var indexSummary = string.Join(" | ", indexes.Select(BuildIndexSummary)); + + // 唯一索引:playerId_1 + Assert.Contains(indexes, x => HasKey(x, "playerId") && IsUnique(x)); + // TTL 索引:lastSeenAt_1(带 expireAfterSeconds 等于 30 天) + Assert.Contains(indexes, x => HasKey(x, "lastSeenAt") && HasTtl(x)); + } + + private static string BuildIndexSummary(BsonDocument index) + { + return index["name"].AsString; + } + + private static bool HasKey(BsonDocument index, string field) + { + var key = index["key"].AsBsonDocument; + return key.Contains(field); + } + + private static bool IsUnique(BsonDocument index) + { + return index.Contains("unique") && index["unique"].ToBoolean(); + } + + private static bool HasTtl(BsonDocument index) + { + if (!index.Contains("expireAfterSeconds")) + { + return false; + } + + var seconds = index["expireAfterSeconds"].ToInt64(); + return seconds == PlayerRouteCollection.TtlSeconds; + } + + [Fact] + public async Task Resolver_Tier1Hit_SkipsControlDatabase() + { + if (ShouldSkip) + { + return; + } + + var database = CreateDatabase(); + var collection = database.GetCollection(PlayerRouteCollection.CollectionName); + await PlayerRouteCollection.EnsureIndexesAsync(collection); + + // 控制库故意写错数据(role=other, version=999):如果 resolver 走到 Tier 2 就会拿到错误结果 + await collection.InsertOneAsync(PlayerRouteCollection.CreateOnline(playerId: 201, instanceId: "other-1", role: "Other")); + + var fastPath = new OnlineFastPath(playerId: 201, serverType: "Game", serverId: 7, version: 1); + var resolver = new MongoPlayerRouteResolver(database, fastPath); + + var resolved = await resolver.ResolveAsync(201); + + Assert.True(resolved.IsOnline); + Assert.Equal("Game", resolved.ServerType); + Assert.Equal(7, resolved.ServerId); + Assert.Equal(1, fastPath.Calls); + } + + [Fact] + public async Task Resolver_Tier1Miss_Tier2Hit_ReturnsControlInfo() + { + if (ShouldSkip) + { + return; + } + + var database = CreateDatabase(); + var collection = database.GetCollection(PlayerRouteCollection.CollectionName); + await PlayerRouteCollection.EnsureIndexesAsync(collection); + + await collection.InsertOneAsync(PlayerRouteCollection.CreateOnline(playerId: 202, instanceId: "7", role: "Game")); + + var fastPath = new OnlineFastPath(playerId: 999, serverType: "X", serverId: 1); + var resolver = new MongoPlayerRouteResolver(database, fastPath); + + var resolved = await resolver.ResolveAsync(202); + + Assert.True(resolved.IsOnline); + Assert.Equal("Game", resolved.ServerType); + Assert.Equal(7, resolved.ServerId); + } + + [Fact] + public async Task Resolver_AllTiersMiss_ReturnsOffline() + { + if (ShouldSkip) + { + return; + } + + var database = CreateDatabase(); + var collection = database.GetCollection(PlayerRouteCollection.CollectionName); + await PlayerRouteCollection.EnsureIndexesAsync(collection); + + var fastPath = new OnlineFastPath(playerId: 999, serverType: "X", serverId: 1); + var resolver = new MongoPlayerRouteResolver(database, fastPath); + + var resolved = await resolver.ResolveAsync(303); + + Assert.False(resolved.IsOnline); + } + + /// + /// Tier 1 测试替身:永远对指定玩家返回在线。 + /// + private sealed class OnlineFastPath : IPlayerRouteFastPath + { + public OnlineFastPath(long playerId, string serverType, int serverId, long version = 1) + { + PlayerId = playerId; + ServerType = serverType; + ServerId = serverId; + Version = version; + } + + public long PlayerId { get; } + public string ServerType { get; } + public int ServerId { get; } + public long Version { get; } + public int Calls { get; private set; } + + public bool TryGetOnline(long playerId, out PlayerRouteInfo info) + { + Calls++; + if (playerId == PlayerId) + { + info = PlayerRouteInfo.Online(ServerType, ServerId, Version); + return true; + } + + info = PlayerRouteInfo.Offline(); + return false; + } + } +} \ No newline at end of file diff --git a/Tests/GameFrameX.Tests/Discovery/MongoPlayerRouteResolverTests.cs b/Tests/GameFrameX.Tests/Discovery/MongoPlayerRouteResolverTests.cs new file mode 100644 index 00000000..13f43a25 --- /dev/null +++ b/Tests/GameFrameX.Tests/Discovery/MongoPlayerRouteResolverTests.cs @@ -0,0 +1,139 @@ +// ========================================================================================== +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// are protected by the laws of the People's Republic of China and relevant international regulations. +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// This project is licensed solely under the Apache License 2.0, +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Routing; + +namespace GameFrameX.Tests.Discovery; + +/// +/// 玩家路由 Tier 1 fast-path 接口契约与 PlayerRouteInfo 工厂测试(C143e D21)。 +/// +/// +/// Pure-logic tests for the player-route resolver contract (C143e D21): +/// the Tier 1 fast-path shape that +/// consumes from the host application layer, and the +/// factory methods. Tier 2 control-database and Tier 3 offline paths run against +/// Mongo and live in MongoPlayerRouteIntegrationTests; the resolver's +/// per-instance CAS upsert is also covered there. The dedupe primitive +/// (PerInstanceDedupe) is left to its native callers — its behavior is +/// already covered by integration tests that exercise the real cross-server +/// send path. +/// +public sealed class MongoPlayerRouteResolverTests +{ + /// + /// 录制型 fast-path 提供方(测试替身)。 + /// + /// + /// Records calls and returns scripted answers; lets the test verify the + /// resolver does not fall through to Tier 2 when Tier 1 returns online. + /// + private sealed class ScriptedFastPath : IPlayerRouteFastPath + { + public ScriptedFastPath(long playerId, bool isOnline, string serverType = "Game", int serverId = 1, long version = 7) + { + PlayerId = playerId; + IsOnline = isOnline; + ServerType = serverType; + ServerId = serverId; + Version = version; + } + + public long PlayerId { get; } + public bool IsOnline { get; } + public string ServerType { get; } + public int ServerId { get; } + public long Version { get; } + public int Calls { get; private set; } + + public bool TryGetOnline(long playerId, out PlayerRouteInfo info) + { + Calls++; + if (playerId == PlayerId && IsOnline) + { + info = PlayerRouteInfo.Online(ServerType, ServerId, Version); + return true; + } + + info = PlayerRouteInfo.Offline(); + return false; + } + } + + [Fact] + public void FastPath_OnlineAnswer_ReturnsOnlineRouteInfo() + { + var fastPath = new ScriptedFastPath(playerId: 42, isOnline: true, serverType: "Game", serverId: 9, version: 5); + + var resolved = fastPath.TryGetOnline(42, out var info); + + Assert.True(resolved); + Assert.True(info.IsOnline); + Assert.Equal("Game", info.ServerType); + Assert.Equal(9, info.ServerId); + Assert.Equal(5, info.Version); + Assert.Equal(1, fastPath.Calls); + } + + [Fact] + public void FastPath_OfflineAnswer_ReturnsOfflineInfoAndFalse() + { + var fastPath = new ScriptedFastPath(playerId: 42, isOnline: false); + + var resolved = fastPath.TryGetOnline(42, out var info); + + Assert.False(resolved); + Assert.False(info.IsOnline); + Assert.Null(info.ServerType); + Assert.Equal(1, info.Version); + } + + [Fact] + public void FastPath_WrongPlayer_AnswersOffline() + { + var fastPath = new ScriptedFastPath(playerId: 42, isOnline: true); + + var resolved = fastPath.TryGetOnline(999, out var info); + + Assert.False(resolved); + Assert.False(info.IsOnline); + } + + [Fact] + public void NullPlayerRouteSyncTarget_DeleteAsync_IsNoOp() + { + NullPlayerRouteSyncTarget.Instance.DeleteAsync(1).GetAwaiter().GetResult(); + } + + [Fact] + public void NullPlayerRouteSyncTarget_UpsertAsync_IsNoOp() + { + NullPlayerRouteSyncTarget.Instance.UpsertAsync(1, "instance", "Game", 1).GetAwaiter().GetResult(); + } + + [Fact] + public void PlayerRouteInfo_Offline_HasIsOnlineFalseAndStableVersion() + { + var info = PlayerRouteInfo.Offline(); + Assert.False(info.IsOnline); + Assert.Null(info.ServerType); + Assert.Equal(0, info.ServerId); + Assert.Equal(1, info.Version); + } + + [Fact] + public void PlayerRouteInfo_Online_RoundTripsFields() + { + var info = PlayerRouteInfo.Online("Game", 42, 9); + Assert.True(info.IsOnline); + Assert.Equal("Game", info.ServerType); + Assert.Equal(42, info.ServerId); + Assert.Equal(9, info.Version); + } +} \ No newline at end of file