Skip to content

Architecture Decisions

This document records significant architectural decisions, their rationale, and planned evolution.


ADR-001 — World Server / Auth Database Decoupling

Status: Planned

Context

Avalon.Server.World/Extensions/ServiceExtensions.cs registers AddAuthDatabase(), which adds AuthDbContext and IAccountRepository (Auth) into the World server's DI container. This creates a direct EF Core dependency between two separately scalable components.

Why This Is Problematic

  • The World server must be able to scale horizontally without a shared database write path.
  • Auth and World can be deployed independently; a World-side migration should not require Auth DB access.
  • Principles of bounded context (DDD) dictate that the World domain operates on its own data.

Root Cause

IAccountRepository (Auth) is used in the World server for: 1. Reading account online status on character selection. 2. Setting account.Online = false on disconnect.

Decision

Replace direct DB access with Redis-backed state:

Current (DB) Target (Redis)
_accountRepository.FindByIdAsync(id) _cache.GetAsync($"account:{id}:session")
account.Online = true; UpdateAsync() _cache.SetAsync($"account:{id}:online", 1)
account.Online = false; UpdateAsync() _cache.DeleteAsync($"account:{id}:online")

The Auth server remains the sole writer of AuthDbContext. It listens on Redis pub/sub for World-side events and updates the DB accordingly.

Migration Path

  1. Auth server: on successful auth, write account:{id}:session JSON (containing AccountId, WorldId, LoginTime) to Redis.
  2. World server: read Redis for session validation; no AuthDbContext.
  3. Auth server: subscribe to world:characters:disconnect and clear DB online state.
  4. Remove AddAuthDatabase() from World DI.
  5. Integration test: World host starts without AuthDbContext in service collection.

ADR-002 — Chat Command Handler Architecture

Status: Implemented

Context

ChatMessageHandler processes all CChatMessagePacket packets. Slash commands (/invite, /who, etc.) are conceptually different from free-text chat.

Decision

A Command Dispatcher pattern routes slash-prefixed messages to ICommand implementations:

CChatMessagePacket
ChatMessageHandler
  message.StartsWith('/') ?
        ├── YES → CommandDispatcher.DispatchAsync(ctx, commandLine)
        │              └── Resolve ICommand by name or alias
        │                    ├── Found → ICommand.ExecuteAsync(ctx, args)
        │                    └── Not Found → send "Unknown command" to sender
        └── NO  → BroadcastToChunk (existing behaviour)

ICommand Interface

public interface ICommand
{
    string Name { get; }
    string[] Aliases { get; }
    Task ExecuteAsync(WorldPacketContext<CChatMessagePacket> ctx, string[] args,
                      CancellationToken token = default);
}

DI Registration

services.AddSingleton<ICommand, GroupInviteCommand>();
services.AddSingleton<ICommand, WhoCommand>();
// etc.

CommandDispatcher resolves all ICommand registrations via IEnumerable<ICommand> injection, building a lookup by Name and Aliases (case-insensitive). Unknown commands reply with "Unknown command."


ADR-003 — World Timer Constants

Status: Planned

Context

World.cs defines WorldTimersCount = 5 and only names HotReloadTimer = 0. Timers 1–4 are either unnamed or unused.

Decision

Audit and name all timers. If fewer than 5 are used, reduce WorldTimersCount.

private const ushort WorldTimersCount  = 2; // adjust after audit
private const ushort HotReloadTimer    = 0;
private const ushort WorldSaveTimer    = 1; // periodic state persistence (if used)

All _timers[N] accesses must use the named constant. This is primarily a code clarity change with no runtime behaviour impact.


ADR-004 — CharacterSpell Specializations

Status: Design decision pending

Context

CharacterSpell has an open question about whether a spell learned by a character can be "specced" into a branch that modifies its class, damage, area, or animation.

Options

Option A — Enum-based path (simple)

public SpecializationPath? Specialization { get; set; } // nullable

Where SpecializationPath is a flat enum. Specialization influences SpellScript variant selection by the IScriptManager.

Option B — FK to a tree node (extensible)

public SpecializationNodeId? SpecializationNodeId { get; set; }

A SpecializationNode table holds a tree structure. More flexible but more complex to implement.

Decision

Implement Option A initially. The SpecializationPath enum starts with a small set (e.g. None, Fire, Frost, Arcane for Wizard). This can be evolved into Option B if the design demands branching trees.

SpellScript Selection with Specialization

// In ScriptManager.GetSpellScript:
Type? GetSpellScript(string scriptName, SpecializationPath? path = null)
{
    if (path.HasValue)
    {
        string variantName = $"{scriptName}_{path}"; // e.g. "Fireball_FireMastery"
        if (_scripts.TryGetValue(variantName, out var variant))
            return variant;
    }
    return _scripts.GetValueOrDefault(scriptName); // default script
}

ADR-005 — FakeMetricsManager Dispose

Status: Planned (trivial)

FakeMetricsManager holds no resources. The Dispose(bool disposing) method should be completed with an idempotency guard:

private bool _disposed;

protected virtual void Dispose(bool disposing)
{
    if (_disposed) return;
    // No managed or unmanaged resources to release.
    _disposed = true;
}

public void Dispose()
{
    Dispose(true);
    GC.SuppressFinalize(this);
}

Component Boundary Map

┌───────────────────────────────────────────────────────────────────────┐
│                         Game Client                                    │
└──────────────────────────────┬────────────────────────────────────────┘
                               │ TCP (custom packet protocol)
          ┌────────────────────┼───────────────────────┐
          ▼                    │                        ▼
┌─────────────────┐            │             ┌──────────────────────┐
│  Auth Server    │◄───────────┘             │    World Server      │
│ (ticket issuer) │  ──── Redis pub/sub ────►│ (simulation engine)  │
│                 │  ◄─── Redis pub/sub ────  │                      │
└────────┬────────┘                          └──────────┬───────────┘
         │ EF Core                                      │ EF Core
         ▼                                              ▼
┌─────────────────┐                          ┌──────────────────────┐
│   Auth DB       │                          │  Character + World DB│
│ (accounts, MFA) │          Redis           │ (characters, items,  │
└─────────────────┘    (sessions, cache,     │  world templates)    │
                        pub/sub)             └──────────────────────┘

         ┌─────────────────────────────────────────────┐
         │              REST API                        │
         │  (account mgmt, OpenAPI, JWT issuance)       │
         └──────────────┬──────────────────────────────┘
                        │ EF Core + Redis
                Both databases + Redis

The World server currently has a direct coupling to AuthDbContext (via AddAuthDatabase()). ADR-001 describes the plan to replace this with Redis-only communication.