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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions