Skip to content

Cross-Server Shared Event Maps - Epic #818

Description

@eduardosmaniotto

Cross-Server Shared Event Maps

Depends on: existing map/gate system, existing GameServerHeartbeat pub/sub

Scope

Intra-group subserver switching via 0xC3:0xB1:0x00/0xC3:0xB1:0x01.

Glossary

Architecture

LoginServer mediates all server moves. Game servers call one BeginTransferAsync, LoginServer validates capacity (via existing heartbeat data), generates auth, updates _connectedAccounts, and returns target IP/port. Forward trip position stored in LoginServer memory (30s). Origin server/map persisted on Character DB for return-trip routing.

IP + port + capacity come from the existing GameServerHeartbeat pub/sub (published by GameServerStatePublisher, already consumed by ConnectServer). LoginServer.Host subscribes to the same topic — no new registration RPC needed.

sequenceDiagram
    participant C as Client
    participant GS as Source GameServer
    participant LS as LoginServer
    participant GS2 as Target GameServer

    Note over C,GS2: Forward Trip / Login Redirect / Return Trip
    C->>GS: Enter gate / Select character
    GS->>GS: Check SharedMapLocator

    alt Map is global, hosted elsewhere
        GS->>LS: BeginTransferAsync(account, from, to, map)
        LS->>LS: Check capacity via heartbeat<br/>Generate auth[4]<br/>Update _connectedAccounts
        LS-->>GS: TransferResult (auth, IP, port)
        GS->>GS: Save OriginServerId to DB
        GS-->>C: 0xB1:0x00 (auth, IP, port)
        GS->>GS: Disconnect client
    else Normal flow
        GS->>GS: Local warp / EnterWorld
    end

    Note over C,GS2: Auth Validation
    C->>GS2: Connect + 0xB1:0x01 (auth)
    GS2->>LS: ValidateAuth(auth)
    LS->>LS: Match auth, clear PendingMove<br/>Return DestMapNumber
    LS-->>GS2: DestMapNumber
    GS2->>GS2: Load account from DB<br/>Set Player.Account<br/>Find character, set CurrentMap
    GS2->>GS2: Set Player.SelectedCharacter
    Note over GS2: OnPlayerEnteredWorldAsync fires<br/>Party/guild/friends wired
Loading

Sprints

Sprint 1 — Data Model

Add config entities for global map assignment and origin columns on Character for return-trip routing.

// src/DataModel/Configuration/GameMapDefinition.cs
public MapHostingMode HostingMode { get; set; }

// src/DataModel/Configuration/MapHostingMode.cs — NEW
public enum MapHostingMode { PerSubServer, Global }

// src/DataModel/Configuration/GlobalMapHostAssignment.cs — NEW
[Cloneable]
public class GlobalMapHostAssignment
{
    public short MapNumber { get; set; }
    public virtual GameServerDefinition? HostSubServer { get; set; }
}

// src/DataModel/Configuration/GameConfiguration.cs — add owning collection
[MemberOfAggregate]
public virtual ICollection<GlobalMapHostAssignment> GlobalMapHostAssignments { get; protected set; } = null!;

// src/DataModel/Entities/Character.cs — add two nullable columns
public byte? OriginServerId { get; set; }
public short? OriginMapNumber { get; set; }

// src/DataModel/Configuration/GameServerConfiguration.cs — add
public virtual ICollection<GameMapDefinition> HostedSharedMaps { get; protected set; } = null!;

Files: 2 new, 4 modified + EF migration


Sprint 2 — LoginServer Contract + Auth State + Map Host Resolver

Extend ILoginServer with BeginTransferAsync. Add a named helper to resolve map→host server (used by gate, warp, and login redirect — single source of truth). LoginServer subscribes to existing GameServerHeartbeat topic for capacity + IP/port data.

// src/GameLogic/SharedMapLocation.cs — NEW
public record SharedMapLocation(bool IsGlobal, byte? HostServerId, GameMapDefinition? Definition);

// src/GameLogic/ISharedMapLocator.cs — NEW
public interface ISharedMapLocator
{
    SharedMapLocation LocateGlobalMap(short mapNumber);
}

Implementation: filters GameConfiguration.GlobalMapHostAssignments, resolves host from HostSubServer.ServerID.

ISharedMapLocator added as a property on IGameServerContext (same pattern as LoginServer, GuildServer, FriendServer). All call sites access it via ((IGameServerContext)player.GameContext).SharedMapLocator.

// src/Interfaces/ILoginServer.cs — add

/// <summary>
/// Initiates a server-to-server transfer. Called by source game server for
/// live teleport, or by any game server for login redirect.
///
/// LoginServer:
///   - checks target capacity via heartbeat data
///   - generates 4 auth codes, stores (auth[4], destMapNumber) for 30s
///   - removes old server from _connectedAccounts, inserts new server
///   - returns auth codes + target address on success, error code on failure
/// </summary>
ValueTask<TransferResult> BeginTransferAsync(
    string accountName, byte fromServerId, byte toServerId, short destMapNumber);

// Discriminated result — success has payload, failure has only code
public readonly record struct TransferPayload(
    uint Auth1, uint Auth2, uint Auth3, uint Auth4,
    string TargetIp, ushort TargetPort);

public readonly record struct TransferResult(
    TransferResultCode Code, TransferPayload? Payload = null);

public enum TransferResultCode { Success, TargetOffline, TargetFull, InternalError }

LoginServer internal additions:

  • _pendingMoves: Dictionary<string, PendingMove> — keyed by account name
  • record PendingMove(byte ServerId, short DestMapNumber, uint[] Auth, uint Tick) — Tick for 30s expiry
  • BeginTransferAsync: lookup target server from heartbeat data → check CurrentConnections < MaxConnections → store PendingMove → update _connectedAccounts[account] = toServerId → return auth + address from heartbeat
  • Auth validation handler: match auth → clear PendingMove → return DestMapNumber
  • LoginServer.Host subscribes to GameServerHeartbeat topic (same as ConnectServer already does) — populates ConcurrentDictionary<byte, ServerHeartbeatData> with current connections, max connections, public endpoint
  • No new IGameServer additions needed

Files: 2 new (SharedMapLocation.cs, ISharedMapLocator.cs), 1 modified


Sprint 3 — Packet, View Plugin, and Handler

Server → Client 0xC3:0xB1:0x00 — MapServerChange (38 bytes)

Offset Type Field
0-3 C3HeaderWithSubCode { Type=0xC3, Code=0xB1, SubCode=0x00, Length=38 }
4-19 char[16] ServerIpAddress
20-21 ushort ServerPort
22-23 ushort ServerCode
24-27 uint AuthCode1
28-31 uint AuthCode2
32-35 uint AuthCode3
36-37 uint AuthCode4

Client → Server (0xC3:0xB1:0x01, 69 bytes) already exists in codebase.

New group handler for 0xB1:

  • MapServerChangeGroupHandler — extends GroupPacketHandlerPlugIn, GroupKey = 0xB1
  • ServerChangeAuthenticationHandlerPlugIn[BelongsToGroup(0xB1)], Key = 0x01
// src/GameLogic/Views/World/IMapServerChangePlugIn.cs — NEW
public interface IMapServerChangePlugIn : IViewPlugIn
{
    ValueTask MapServerChangeAsync(string ip, ushort port, ushort serverCode,
        uint auth1, uint auth2, uint auth3, uint auth4);
}

// src/GameServer/RemoteView/World/MapServerChangePlugIn.cs — NEW

// src/GameServer/MessageHandler/MapServerChangeGroupHandler.cs — NEW

// src/GameServer/MessageHandler/ServerChangeAuthenticationHandlerPlugIn.cs — NEW

ServerChangeAuthenticationHandlerPlugIn flow:

  1. Deserialize 69-byte packet, Xor3-decode account/character name
  2. Validate auth codes with LoginServer → get DestMapNumber
  3. Load account from DB: player.PersistenceContext.GetAccountByLoginNameAsync(accountName)
  4. Set player.Account = loadedAccount
  5. Find target character in loadedAccount.Characters by decoded character name
  6. Set character's CurrentMap to dest map, position to map spawn gate
  7. Set Player.SelectedCharacter = character — this triggers OnPlayerEnteredWorldAsync (requires player.Account to be set), which fires PlayerEnteredWorld event that wires up party rejoin, guild registration, friend online state. Do NOT manually place character on map.

Files: 4 new


Sprint 4 — Teleport Logic

Wire BeginTransferAsync + ISharedMapLocator into gate/warp flows.

// In a shared helper:
async ValueTask<bool> TryCrossServerMoveAsync(Player player, ExitGate targetGate)
{
    var gsc = (IGameServerContext)player.GameContext;
    var location = gsc.SharedMapLocator.LocateGlobalMap(targetGate.Map.Number);

    if (!location.IsGlobal || location.HostServerId == gsc.Id)
        return false;

    var result = await gsc.LoginServer.BeginTransferAsync(
        player.Account!.LoginName, gsc.Id, location.HostServerId!.Value,
        targetGate.Map.Number);

    if (result.Code != TransferResultCode.Success)
    {
        // error feedback to player
        return true;
    }

    var payload = result.Payload!;
    // Save origin for return trip
    player.SelectedCharacter!.OriginServerId = gsc.Id;
    player.SelectedCharacter!.OriginMapNumber = player.SelectedCharacter!.CurrentMap!.Number;
    // persist to DB

    await player.InvokeViewPlugInAsync<IMapServerChangePlugIn>(
        p => p.MapServerChangeAsync(payload.TargetIp, payload.TargetPort,
            location.HostServerId.Value, payload.Auth1, payload.Auth2, payload.Auth3, payload.Auth4));
    player.Client.Disconnect();
    return true;
}

Called from WarpGateAction.EnterGateAsync and WarpAction.WarpToAsync.

GameServerMapInitializer creates global map instances for HostedSharedMaps.

Files: 1 new, 3 modified


Sprint 5 — Login Redirect + Return Trip + Edge Cases

Login redirectSetSelectedCharacterAsync:

var gsc = (IGameServerContext)player.GameContext;
var location = gsc.SharedMapLocator.LocateGlobalMap(player.SelectedCharacter!.CurrentMap!.Number);
if (location.IsGlobal && location.HostServerId != gsc.Id)
{
    var result = await gsc.LoginServer.BeginTransferAsync(
        player.Account!.LoginName, gsc.Id, location.HostServerId!.Value,
        player.SelectedCharacter!.CurrentMap!.Number);
    // send 0xB1:0x00 via IMapServerChangePlugIn, disconnect
}

Return tripWarpGateAction:

var character = player.SelectedCharacter!;
if (character.OriginServerId != null)
{
    var result = await gsc.LoginServer.BeginTransferAsync(
        player.Account!.LoginName, gsc.Id, character.OriginServerId.Value,
        character.OriginMapNumber!.Value);

    character.OriginServerId = null;
    character.OriginMapNumber = null;
    // persist to DB
    // send 0xB1:0x00 via IMapServerChangePlugIn, disconnect
}

Edge cases

Scenario Handling
Target offline No heartbeat data → TargetOffline
Target full Heartbeat CurrentConnections >= MaxConnectionsTargetFull
Target full (reconnect) Login redirect → if host full → redirect to OriginServerId/OriginMapNumber; fallback to host safe zone
Player quits on shared map, rejoins Login redirect on SetSelectedCharacterAsync
Mid-transfer disconnect Auth codes expire after 30s. Character on global map in DB → login redirect.
Server crash Origin persisted on Character. Reconnect → login redirect. If host still down → TargetOffline → player cannot log in until host recovers.
Auth code replay 30s expiry + _connectedAccounts already updated prevents it.
Party / Trade SelectedCharacter setter triggers party rejoin on arrival. Trade cancelled (can't persist across servers).

Notes

  • IP/port/capacity from existing GameServerHeartbeat pub/sub. LoginServer subscribes — no new RPC.
  • _connectedAccounts[account] atomically updated inside BeginTransferAsync.
  • GlobalMapHostAssignments owned by GameConfiguration with [MemberOfAggregate].
  • Auth handler sets Player.SelectedCharacterOnPlayerEnteredWorldAsync wires party/guild/friend.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions