Files
ZMMO/ARQUITETURA_SERVER_SELECT.md
2026-05-23 02:16:27 -03:00

32 KiB
Raw Blame History

Arquitetura: Server Select + Mundos + Handoff CharServer↔WorldServer

Status: plano de implementação + extensão da arquitetura. Quando aprovado, este documento vira Clients/ZMMO/Docs/ARQUITETURA_SERVER_SELECT.md (ou path equivalente em Server/) — é referência permanente, não plano descartável.

Context

Hoje a tela de Server Select carrega após o login mas é um mock visual sem dados reais:

  • worldHost/worldPort no S_CHAR_SELECT_OK está hardcoded em 127.0.0.1:27777.
  • handoffToken é gerado aleatório no CharServer mas nada valida no WorldServer.
  • Não existe tabela worlds no schema; não existe opcode pra listar mundos.
  • characters não tem world_id — qualquer char joga em qualquer mundo.
  • WorldServer não reporta população/status; CharServer não sabe quem está online onde.

Esta frente desenha a arquitetura "de verdade": múltiplos mundos cadastrados, criação de personagem amarrada ao mundo escolhido, handoff seguro com ticket validado, e uma base que escale pra multi-node + multi-region. Cobre desde o desenho conceitual até execução em fases pequenas e verificáveis.


Princípios arquiteturais (consolidado, após autocrítica)

1. Separação de responsabilidades

Responsabilidade Quem é dono Justificativa
Identidade do jogador (account, JWT, sessões) CharServer + MySQL global Auth precisa ser único cross-region. JWT habilita multi-node sem state replication.
Persistência de personagens (chars, inventário, level) CharServer + MySQL global Habilita transfer, cross-world social, char list multi-mundo. Padrão AccelByte/FFXIV.
Estado vivo de mundos (pop, queue, status) WorldServer → Valkey (regional) Source-of-truth do mundo é quem hospeda o mundo. CharServer só lê e propaga.
Simulação autoritativa (movimento, combate, IA) WorldServer C++ Unreal Latência baixa + tick fixo + autoridade contra cheat.
Tickets de handoff (single-use, TTL curto) CharServer emite, Valkey armazena, WorldServer consome Padrão GameLift: lobby decide quem entra.

2. Quatro canais de comunicação (sem sobreposição)

CharServer já roda dois listeners simultâneos (buildServer em core/server.ts): uWebSockets.js na porta 7100 (cliente Unreal, path /zeus) e Fastify HTTPS na porta 7101 (control plane). É a base do padrão AccelByte: WSS pra push real-time pro cliente, HTTPS pra chamadas pontuais e tráfego servidor-a-servidor.

Canal Quem fala com quem Protocolo Onde está Uso
A Cliente ↔ CharServer WebSocket (WSS) binário Zeus wss://...:7100/zeus Login, char list/create/select, lista de mundos, push de status, queue updates, chat (futuro)
B Cliente ↔ WorldServer UDP binário Zeus UZeusNetworkSubsystemZeusNet/UdpServer Handshake + gameplay autoritativo. Cliente apresenta ticket no C_CONNECT_REQUEST
C CharServer ↔ WorldServer HTTPS REST (Fastify) :7101/interserver/* Claim, character RPC, audit, kick (mTLS na Fase 6)
D Admin/CLI ↔ CharServer HTTPS REST + CLI direto MySQL :7101/admin/* e CLI npm run world CRUD de mundos, métricas, health

Regras invioláveis:

  • Cliente Unreal NUNCA fala HTTP com o CharServer. Tudo é WSS via opcodes Zeus.
  • Cliente Unreal NUNCA consulta o WorldServer pra ver pop/status. Sempre via CharServer (canal A).
  • WorldServer NUNCA escreve direto no MySQL — sempre via canal C (validação + auditoria).

3. Pop tracking: três triggers combinados (não escolher um — usar todos)

WorldServer é o único que sabe a verdade sobre pop/queue/state. Reporta via Valkey (Fase 2+); na Fase 1 reporta via HTTP POST como fallback temporário.

Trigger O que faz Frequência
Event-driven (player entrou/saiu) HINCRBY world:{id}:status pop ±1 + PUBLISH world:events Imediato
State-change (online↔maintenance↔offline) HSET world:{id}:status state=... + PUBLISH world:events Imediato
Periodic heartbeat HSET world:{id}:status ... + PEXPIRE 30000 (reset TTL) A cada 5s

Por que os três: events dão real-time; periodic reconcilia se um PUBLISH foi perdido; TTL detecta world morto automaticamente em ~30s sem código extra.

4. Topologia geo-distribuída

                    ┌────────────────────────────────────────┐
                    │  MySQL Primary (writes globais)        │
                    │  ex.: us-east-1 ou Hetzner DE          │
                    └──────────┬─────────────────────────────┘
                       │  async replication (<100ms)
       ┌───────────────┼───────────────┐
       ▼               ▼               ▼
   read-replica    read-replica    read-replica
      (NA)            (SA)            (EU)
       │               │               │
┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐
│ CharServer  │ │ CharServer  │ │ CharServer  │
│ node × N    │ │ node × N    │ │ node × N    │
│ Valkey (NA) │ │ Valkey (SA) │ │ Valkey (EU) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
   na.zeusmmo…     sa.zeusmmo…     eu.zeusmmo…
       ▲               ▲               ▲
   WorldServers    WorldServers    WorldServers
   na região NA    na região SA    na região EU
       ▲               ▲               ▲
   Clientes NA      Clientes SA      Clientes EU
   (GeoDNS/CDN)     (GeoDNS/CDN)     (GeoDNS/CDN)

Regras:

  1. WorldServer fala apenas com CharServer da sua região (CharServerEndpoint no server.json).
  2. Cliente conecta no DNS regional via GeoDNS. Não cruza região durante sessão.
  3. MySQL writes globais (primary central); reads na replica regional.
  4. Valkey é por-região (estado vivo é local — sem cross-region sync).
  5. Lista de mundos é regional por default (padrão FFXIV — cliente vê só os mundos da account.region).
  6. ZEUS_REGION env var em cada CharServer node; region no worlds.region deve bater com a região do CharServer que recebe o claim do WorldServer.
Região DNS público MySQL replica Valkey cluster
Brasil/SA sa.zeusmmo.com.br sa-east-1 cluster SA
North America na.zeusmmo.com.br us-east-1 cluster NA
Europe eu.zeusmmo.com.br eu-central-1 cluster EU
Asia as.zeusmmo.com.br ap-southeast-1 cluster AS

Componentes-chave

Identidade do mundo: admin-create + WorldServer-claim

Híbrido (estilo AccelByte Armada). Três alternativas e por que escolhemos esta:

Padrão Veredito
Auto-register em runtime ✗ Restart cria ID novo, órfana chars com FK
Pre-config puro (estilo WoW) ✗ Sem prova de identidade — qualquer processo manda heartbeat falso
Admin-create + claim com secret ✓ ID estável + prova de posse + auditoria

Fluxo:

  1. Admin via CLI: npm run world -- create --name=Aurora --host=... --port=... --region=br --cap=500 → CharServer gera world_id (UUID) + world_secret (32 bytes base64url). Output mostra os dois uma vez. secret_hash (Argon2id) é persistido em worlds.secret_hash.
  2. Admin copia world_id + world_secret pro Config/server.json do WorldServer.
  3. WorldServer no boot: POST /interserver/worlds/{id}/claim { secret }. CharServer valida Argon2id contra secret_hash + region match. Sucesso → state=online. Falha → 403 + WorldServer aborta startup.
  4. WorldServer começa heartbeat (Fase 1 HTTP; Fase 2+ Valkey direto).
  5. WorldServer crashado → TTL Valkey expira em 30s → state=offline automático.

Ticket de handoff: opaque token single-use

Hoje: handoffToken é random sem validação. Inseguro.

Padrão escolhido: opaque token 32 bytes (crypto.randomBytes), TTL 10s (não 30 — janela menor pra replay), armazenado em Valkey, consumido via GETDEL no WorldServer (single-use).

CharServer ao emitir:
  payload = { account_id, char_id, world_id, expires_at }
  SETEX ticket:{token} 10 <payload-json>
  INCR world:{world_id}:reserved        ← reserva slot (anti-overbooking)
  responde S_CHAR_SELECT_OK(worldHost, worldPort, token)

Cliente:
  conecta UDP no World, envia C_CONNECT_REQUEST(nonce) + ticket

WorldServer:
  GETDEL ticket:{token}                  ← consome
  valida: world_id==EU? char_id válido? não expirou?
  se OK:
    DECR world:{world_id}:reserved      ← libera reserva
    HINCRBY world:{world_id}:status pop 1
    PUBLISH world:events ...
    fetch char data via canal C → spawn
  se NOK:
    S_CONNECT_REJECT

Por que opaque + Valkey lookup (não JWT): revogação trivial (DEL), single-use natural (GETDEL), payload menor no UDP (32 bytes vs ~200 do JWT). Round-trip Valkey de ~3-5ms é desprezível no handshake (acontece 1x por sessão de horas).

Single sign-on enforcement (sem char duplicado in-world)

Sem isso, dois clientes podem entrar com mesmo char → item dup, corrupção. Acontece em todo MMO sem proteção.

CharServer ao emitir ticket (antes do SETEX):
  active = GET char:active:{char_id}
  se active existe (account já está in-world em algum mundo):
    POST /interserver/worlds/{active.world_id}/kick { char_id, reason: "logged_in_elsewhere" }
    aguarda confirmação (com timeout 3s)
    se WorldServer não respondeu: DEL char:active:{char_id} forçado
  prossegue com ticket

WorldServer ao spawnar:
  SETEX char:active:{char_id} 120 '{"world_id":...,"node_id":...}'
  refresh a cada 60s (renova TTL)

WorldServer ao despawnar (logout/crash):
  DEL char:active:{char_id}

Concurrency control no writeback (sem silent corruption)

Cenário: WorldServer cacheou char na memória; admin via SQL muda level. Próximo writeback do WorldServer sobrescreve. Solução padrão = optimistic locking via version.

characters: version INT UNSIGNED DEFAULT 0
WorldServer carrega char:
  GET /interserver/characters/{id} → guarda version=N

WorldServer writeback:
  POST /interserver/characters/{id}/checkpoint { pos, inv, ..., version: N }
  CharServer SQL:
    UPDATE characters SET ..., version=N+1 WHERE id=$id AND version=$N
  se affected_rows=0:
    409 Conflict
  WorldServer recarrega + faz merge + retry

Capacity com reserva (anti-overbooking)

pop >= cap ingênuo causa overbooking: 50 tickets emitidos não conectaram ainda; pop reporta 450/500; entra mais 50; quando todos conectarem → 501/500. Bug clássico de New World/WoW launch.

Solução padrão: ticket emitido reserva slot em Valkey:

Capacidade real = pop + reserved < cap
  pop      = HGET world:{id}:status pop
  reserved = GET world:{id}:reserved (decai com expiração natural)

Emit ticket:
  if pop + reserved >= cap: enfileira (Fase 4) ou reject WorldFull
  else: SETEX ticket:{token} 10 ... + INCR world:{id}:reserved

WorldServer consome ticket:
  DECR world:{id}:reserved + INCR pop

Ticket expira sem ser consumido (timeout):
  CharServer monitora via keyspace notifications ou cleanup periódico → DECR world:{id}:reserved

QueryPort: dois usos distintos (separar sempre)

Uso Quem consome Como Zeus faz Quando
(A) Estado vivo pro cliente do jogo Cliente Unreal logado no CharServer HTTPS interno (Fase 1) → push Valkey (Fase 2+). Cliente sempre via WSS/CharServer Implementado nesta frente
(B) Visibilidade externa pública Steam server browser, Battlemetrics, GameTracker UDP A2S (protocolo Valve Source), porta 27015, rate-limited Roadmap Fase 7 (Steam launch)

WorldServer não usa QueryPort UDP pra (A) — gasto desnecessário e expõe estado interno. (B) é uma listener UDP separado, anônimo, rate-limited.


Schema MySQL (Fase 1)

Tabela worlds

@Entity({ name: 'worlds' })
export class WorldEntity {
  @ZUuid({ primary: true })
  id!: Buffer;  // UUID v4 binary(16)

  @Column({ type: 'varchar', length: 64, unique: true })
  name!: string;

  @ZEnum(['na', 'sa', 'eu', 'br', 'as'])
  region!: AccountRegion;

  @Column({ type: 'varchar', length: 255 })
  host!: string;

  @Column({ type: 'smallint', unsigned: true })
  port!: number;

  @Column({ type: 'smallint', unsigned: true, default: 500 })
  capacity!: number;

  @ZEnum(['online', 'maintenance', 'offline'], { default: 'offline' })
  state!: WorldState;

  /** Hash Argon2id do world_secret. */
  @Column({ name: 'secret_hash', type: 'varchar', length: 255 })
  secretHash!: string;

  @Column({ name: 'last_claim_at', type: 'datetime', nullable: true })
  lastClaimAt!: Date | null;

  @Column({ name: 'last_claim_node', type: 'varchar', length: 64, nullable: true })
  lastClaimNode!: string | null;

  @CreateDateColumn() createdAt!: Date;
  @UpdateDateColumn() updatedAt!: Date;
}

Alteração em characters

A entity atual (Character.entity.ts) tem só campos básicos (id, account_id, slot, name, class_id, level, exp, posição, appearance). A Fase 1 adiciona:

ALTER TABLE characters
  -- Roteamento de mundo
  ADD COLUMN world_id BINARY(16) NULL,
  ADD CONSTRAINT fk_characters_world FOREIGN KEY (world_id) REFERENCES worlds(id) ON DELETE RESTRICT,
  ADD INDEX idx_characters_account_world (account_id, world_id),

  -- Concurrency control (writeback Fase 3)
  ADD COLUMN version INT UNSIGNED NOT NULL DEFAULT 0,

  -- Stats primários estilo Ragnarok (ver ARQUITETURA_CHARACTER_MODEL.md)
  ADD COLUMN str SMALLINT UNSIGNED NOT NULL DEFAULT 1,
  ADD COLUMN agi SMALLINT UNSIGNED NOT NULL DEFAULT 1,
  ADD COLUMN vit SMALLINT UNSIGNED NOT NULL DEFAULT 1,
  ADD COLUMN `int` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
  ADD COLUMN dex SMALLINT UNSIGNED NOT NULL DEFAULT 1,
  ADD COLUMN luk SMALLINT UNSIGNED NOT NULL DEFAULT 1,

  -- Progressão (já tem level/exp = base; adicionar job_level/job_exp)
  ADD COLUMN job_level INT UNSIGNED NOT NULL DEFAULT 1,
  ADD COLUMN job_exp BIGINT UNSIGNED NOT NULL DEFAULT 0,

  -- HP/SP atuais e máximos (max é cache; recalcula em level/equip change)
  ADD COLUMN hp INT UNSIGNED NOT NULL DEFAULT 40,
  ADD COLUMN max_hp INT UNSIGNED NOT NULL DEFAULT 40,
  ADD COLUMN sp INT UNSIGNED NOT NULL DEFAULT 11,
  ADD COLUMN max_sp INT UNSIGNED NOT NULL DEFAULT 11,

  -- Pontos não-gastos (allocação no level up)
  ADD COLUMN status_point INT UNSIGNED NOT NULL DEFAULT 0,
  ADD COLUMN skill_point INT UNSIGNED NOT NULL DEFAULT 0,

  -- Moeda
  ADD COLUMN zeny BIGINT UNSIGNED NOT NULL DEFAULT 0;

Notas:

  • world_id é nullable (sem chars em prod hoje — evita downtime futuro). Validação no service: novos chars devem ter world_id.
  • Estrutura flat segue rathena (.bases/rathena/rathena-master/sql-files/main.sql:209-296) — padrão da indústria emuladora. Sem entidade character_stats separada (1:1 FK seria JOIN inútil).
  • Defaults vêm da row Aprendiz no jobs.yml (ver doc Character Model).
  • Stats derivados (ATK/MATK/DEF/MDEF/hit/flee/crit/aspd) NÃO entram aqui — são sempre recalculados em runtime no WorldServer via fórmulas. Persistir é bug fest (esquece atualizar quando muda equip/buff/level).
  • version (optimistic locking) usado em writeback da Fase 3.

Para o detalhamento do modelo de personagem (stats primários vs derivados, fórmulas de ATK/MATK/DEF, jobs.yml, stat allocation, leveling curve, classes Aprendiz → especialização), ver doc dedicado ARQUITETURA_CHARACTER_MODEL.md.

Schema Valkey (Fase 2+)

world:{id}:status      HSET { pop, cap, queue_len, state, updated_at }   TTL 30s (renovado a cada 5s)
world:{id}:reserved    INTEGER                                             (sem TTL — decai por DECR)
world:events           PUB/SUB channel                                     (transitions + player_in/out)
queue:world:{id}       ZSET { account_id → timestamp_ms }                  (Fase 4, Valkey Streams)
char:active:{char_id}  STRING '{world_id,node_id}'                         TTL 120s (renovado pelo WS)
ticket:{token}         STRING '{account_id,char_id,world_id,expires_at}'  TTL 10s, GETDEL
session:{jti}          HSET { account_id, node_id, ip, expires_at }       TTL = JWT TTL (Fase 5)

Roadmap em fases

Fase 1 — Lista de mundos estática + char por mundo (sem Valkey)

Objetivo: ServerSelect mostra mundos reais; CharSelect filtra por mundo; cada novo char nasce vinculado.

MySQL:

  • Migration worlds + characters.world_id + characters.version.
  • CLI npm run world -- create/list/set-state/rotate-secret.

CharServer (TS):

  • WorldRepository + WorldService (list/get/setState/claim/heartbeat).
  • Opcodes novos:
    • C_WORLD_LIST_REQUEST=2060 / S_WORLD_LIST=2061 (region-aware — filtra por ZEUS_REGION).
    • Estender C_CHAR_LIST_REQUEST com world_id.
    • Estender C_CHAR_CREATE com world_id obrigatório.
  • Endpoints HTTPS (canal C):
    • POST /interserver/worlds/{id}/claim { secret } — Argon2id check + region check → state=online. Rate-limited (1 req/min por IP — anti crash-loop hammering).
    • POST /interserver/worlds/{id}/heartbeat { pop, queue_len, state } — fallback HTTP antes do Valkey (UPDATE em worlds.last_heartbeat_at ou tabela worlds_live auxiliar).
  • S_CHAR_SELECT valida char.world_id == world.id e world.state == online. Reject reasons novos:
    • WorldOffline=8, WorldMaintenance=9, WorldFull=10, WorldRegionMismatch=11.
  • Devolve worldHost/Port do mundo escolhido (não mais hardcoded).

Cliente Unreal:

  • Novo estado EZMMOFrontEndState::CharSelect entre ServerSelect e ingame.
  • UUIServerSelectScreen_Base: pede lista, popula cards, clique → SelectedWorldId no UUIFrontEndFlowSubsystem + transição CharSelect.
  • UUICharSelectScreen_Base (NOVO): lista filtrada por SelectedWorldId, criar/deletar/entrar.
  • CharServerOpcodes.h: novos opcodes + reasons.

Métricas Prometheus (mínimas):

  • char_auth_total{result}, char_select_total{result}, world_list_request_total.

Files críticos:

  • Server/ZeusCharServer/src/database/entities/MMO/World.entity.ts (NOVO)
  • Server/ZeusCharServer/src/database/entities/MMO/Character.entity.ts (add world_id, version)
  • Server/ZeusCharServer/src/services/world.service.ts (NOVO)
  • Server/ZeusCharServer/src/http/routes/interserver.ts (estender)
  • Server/ZeusCharServer/src/services/char-{list,create,select}.service.ts
  • Server/ZeusCharServer/scripts/world-cli.ts (NOVO)
  • Server/ZeusCharServer/src/protocol/CharOpcodes.ts
  • Clients/ZMMO/Source/ZMMO/Game/UI/FrontEnd/UIServerSelectScreen_Base.{h,cpp}
  • Clients/ZMMO/Source/ZMMO/Game/UI/FrontEnd/UICharSelectScreen_Base.{h,cpp} (NOVO)
  • Clients/ZMMO/Source/ZMMO/Data/UI/FrontEndTypes.h

Fase 2 — Valkey + heartbeat real

Objetivo: pop/state real-time no ServerSelect; UI atualiza sem refresh.

Setup:

  • docker-compose.yml com valkey/valkey:7-alpine.
  • ioredis no CharServer (API 100% compatível com Valkey 7.2).
  • Cliente Valkey C++ no WorldServer (hiredis — C, popular; ou cpp_redis — header-only, mais moderno; decidir na Fase 2).

WorldServer (C++):

  • Worker thread separada (não bloqueia game loop):
    • On player_connected/disconnected: HINCRBY + PUBLISH.
    • On state_change: HSET state + PUBLISH.
    • Periodic 5s: refresh HSET completo + PEXPIRE 30000.
  • WorldId validado contra worlds no boot via HTTP claim (Fase 1).

CharServer (TS):

  • ValkeyService singleton (ioredis client + subscriber).
  • WorldService.getLiveStatus(id) lê do Valkey (fallback state=offline se key não existe).
  • Novo opcode S_WORLD_STATUS_UPDATE=2062 — push pra cliente quando recebe world:events.
  • S_WORLD_LIST agora inclui pop/queue_len/state do Valkey no payload inicial.

Cliente:

  • ServerSelect: subscribe na ativação; listener S_WORLD_STATUS_UPDATE atualiza cards em tempo real.

Métricas: world_heartbeat_age_seconds{world_id} (alerta >30s), world_population{world_id}.

Fase 3 — Ticket validado + writeback de char + concurrency

Objetivo: handoff seguro + persistência real do gameplay sem corrupção.

CharServer:

  • TicketService.issue({account_id, char_id, world_id}): crypto.randomBytes(32)SETEX ticket:{token} 10 + INCR world:{id}:reserved.
  • S_CHAR_SELECT_OK envia ticket emitido (substitui o random anterior).
  • Endpoints novos (canal C):
    • GET /interserver/characters/{id} — full state (pos, inv, stats, version).
    • POST /interserver/characters/{id}/checkpoint { ..., version } — optimistic UPDATE; 409 se conflict.
    • POST /interserver/audit { type, account_id, char_id, payload } — audit log (level up, item raro, trade).

WorldServer:

  • Handshake UDP lê ticket → GETDEL ticket:{token} no Valkey → valida campos.
  • Se válido: DECR world:{id}:reserved + INCR pop + GET /interserver/characters/{id} → spawn.
  • Worker 60s + on logout: POST /interserver/characters/{id}/checkpoint. Em 409 → recarrega + merge + retry.

Cliente: transparente (só passa o ticket que recebeu).

Métricas: ticket_{issued,consumed,expired}_total, character_writeback_{ok,conflict}_total.

Fase 3.5 — Single sign-on enforcement

Objetivo: impedir char duplicado in-world (item dup, corrupção).

CharServer:

  • Antes de emitir ticket: GET char:active:{char_id}. Se existe e é mundo diferente → POST /interserver/worlds/{active.world_id}/kick { char_id } com timeout 3s. Se timeout → DEL char:active:{char_id} forçado (assumir world morreu) + log warn.

WorldServer:

  • On spawn: SETEX char:active:{char_id} 120 '{world_id, node_id}'.
  • Worker 60s: refresh TTL (PEXPIRE).
  • On despawn/logout/crash recover: DEL char:active:{char_id}.
  • Endpoint POST /admin/kick { char_id, reason }: força disconnect daquele char.

Métricas: sso_kick_total{reason=logged_in_elsewhere|stale}.

Fase 4 — Queue com Valkey Streams (não PUB/SUB)

Objetivo: quando pop + reserved >= cap, enfileirar em vez de rejeitar.

Por que Streams e não PUB/SUB: PUB/SUB é fire-and-forget; CharServer reiniciando perde eventos slot-free → jogadores ficam eternamente na fila. Streams (XADD/XREAD/XACK) persiste mensagens, consumer pode catch-up após restart.

CharServer:

  • Em S_CHAR_SELECT com pop cheio: ZADD queue:world:{id} {ts_ms} {account_id} + responde S_CHAR_QUEUED(position, ETA).
  • Worker push 5s: S_QUEUE_UPDATE pra cada um na fila (com posição via ZRANK).
  • Consumer da stream world:slot-free:{id}: ZPOPMIN da queue → emite ticket → S_CHAR_QUEUE_READY(ticket, worldHost, worldPort).
  • Cleanup de zombie entries: WS disconnect → ZREM do account_id em todas as filas.

WorldServer:

  • Em logout/disconnect: XADD world:slot-free:{id} * world_id={id} (Valkey Stream).

Cliente: tela de fila (overlay), opção "Cancelar" → C_CHAR_QUEUE_CANCEL.

Métricas: queue_length{world_id}, queue_wait_seconds_p99{world_id}.

Fase 5 — Multi-node CharServer

Objetivo: rodar N instâncias atrás de LB dentro de uma região.

  • Sticky session por cookie no gateway (HAProxy/Nginx) — canal A WSS.
  • session:{jti} em Valkey HSET — revogação JWT global via PUBLISH session:revoke.
  • Health endpoint pro LB; readiness probe diferenciado.
  • Connection draining: SIGTERM handler seta draining=true flag, recusa novas conexões WSS mas mantém existentes até timeout (60s).

Métricas: ws_connections_active{node_id}, node_draining{node_id}.

Fase 6 — Multi-region

Objetivo: roll-out NA/SA/EU/AS com DB global.

  • MySQL Primary central + read replicas regionais (async replication).
  • Valkey cluster por região (sem cross-region sync — cada região é uma ilha de estado vivo).
  • DNS regional via GeoDNS (Route53/Cloudflare) ou subdomain estático.
  • WorldServer CharServerEndpoint aponta pro hostname regional.
  • Worlds filtrados por região automaticamente (WHERE region = $ZEUS_REGION).
  • Read-your-own-writes: flag READ_PRIMARY=1 no service layer pra reads críticos imediatamente após write.
  • World transfer cross-region: endpoint admin POST /admin/characters/{id}/transfer { target_world_id }.
  • mTLS interserver (canal C) — cada WorldServer com cert único, CharServer valida chain. Substitui shared secret pra cross-region.
  • JWT key rotation via JWKS endpoint (GET /.well-known/jwks.json) com kid header.
  • Liveness probe externa (não só heartbeat self-reported): CharServer faz GET /health no WorldServer a cada 30s — detecta congelado (heartbeat fluindo + game thread morta).
  • Disaster recovery: GeoDNS roteia pra região mais próxima em catástrofe. Usuários re-loginam.

Fase 7 — WorldServer QueryPort A2S público (sob demanda)

Objetivo: expor WorldServer pra Steam server browser, Battlemetrics, GameTracker.

  • Listener UDP separado (porta 27015 default), independente do gameplay UDP.
  • Protocolo Valve A2S: A2S_INFO (0x54), A2S_PLAYER (0x55), A2S_RULES (0x56) com challenge anti-spoofing (0x41, obrigatório desde 2020).
  • Rate-limit por IP (token bucket 30 req/s).
  • Registro no Steam Master Server (hl2master.steampowered.com:27011).
  • Nunca compartilha identidade com canal C — stateless, anônimo, jamais expõe world_secret.
  • Flag enable_query_port no server.json (default off em dev/CI).
  • Ref: https://developer.valvesoftware.com/wiki/Server_queries

Decisões consolidadas

  1. CharServer dono dos characters (MySQL global). Coluna world_id (FK) + version (optimistic locking).
  2. WorldServer stateless quanto a persistência. Cache em memória + writeback ~60s.
  3. Lista de mundos: worlds no MySQL (durável). Estado vivo (pop/queue/state) em Valkey (Fase 2+).
  4. Heartbeat WorldServer: três triggers combinados (event + state-change + periodic 5s) escrevendo direto no Valkey (Fase 2+); HTTP POST como fallback na Fase 1.
  5. Mundo em manutenção: sempre acessível em ServerSelect/CharSelect; botão "Entrar" rejeita.
  6. Char list filtrada por mundo: escolha do mundo na ServerSelect → CharSelect com chars desse mundo. Criação inclui world_id.
  7. Ticket de handoff: opaque 32-byte token, TTL 10s, GETDEL no WorldServer, com reserva de slot (INCR world:{id}:reserved).
  8. SSO enforcement: char:active:{char_id} em Valkey + kick old session via canal C antes de emitir ticket novo.
  9. Concurrency control: coluna version em characters, optimistic UPDATE no writeback (409 → retry com merge).
  10. Queue: MVP sem fila (Fase 1-3 → reject WorldFull); Fase 4 implementa via Valkey Streams (não PUB/SUB) com cleanup de zombies + reserva integrada.
  11. Valkey (fork BSD do Redis 7.2 mantido pela Linux Foundation): a partir da Fase 2. ioredis no Node funciona sem mudança. Docker valkey/valkey:7-alpine.
  12. world_id: admin-create + WorldServer-claim com Argon2id secret (padrão AccelByte Armada). Rate-limited (anti crash-loop).
  13. Topologia geo-distribuída: Valkey + CharServer por região; MySQL primary global + read replicas regionais. Multi-region é Fase 6.
  14. QueryPort: UDP A2S público apenas pra visibilidade externa (Steam/Battlemetrics) na Fase 7. Estado pro cliente do jogo sempre via WSS/CharServer.
  15. mTLS interserver: Fase 6 (substitui shared secret pra cross-region).
  16. JWT key rotation: JWKS endpoint na Fase 6.
  17. Métricas Prometheus: lista mínima em cada fase (char_auth_total, world_heartbeat_age_seconds, ticket_*_total, queue_length, etc.).

Verificação por fase

Fase 1:

  • npm run world -- create --name="Aurora" --host=127.0.0.1 --port=27777 --region=br --cap=500 → imprime world_id + world_secret.
  • npm run world -- create --name="Volcanus" --host=127.0.0.1 --port=27778 --region=na --cap=500.
  • npm run world -- list mostra ambos com state=offline.
  • WorldServer com world_id+world_secret no Config/server.json sobe → POST /interserver/worlds/{id}/claim204 + log "claimed". state no DB vira online.
  • Claim com secret errado → 403 + abort.
  • PIE login → ServerSelect: 2 cards reais.
  • Clique em Aurora → CharSelect com chars de world_id=Aurora (filtrado).
  • Criar Personagem → INSERT com world_id=Aurora.
  • npm run world -- set-state <id> maintenance → entrar → reject WorldMaintenance (CharSelect ainda funciona).

Fase 2:

  • docker-compose up -d valkey sobe Valkey.
  • valkey-cli HGETALL world:{id}:status mostra pop fresh.
  • Player conecta → ServerSelect: pop incrementa em tempo real sem refresh.
  • Matar WorldServer → 30s depois ServerSelect mostra "Offline".

Fase 3:

  • WorldServer log "ticket validado, account=X char=Y".
  • Reuse de mesmo ticket → reject.
  • Editar inv in-game → 60s depois SELECT inv FROM characters mostra novo state.
  • Editar level via SQL direto durante gameplay → writeback do WorldServer dá 409 → recarrega + merge.

Fase 3.5:

  • Logar com char Y no PIE 1.
  • Logar mesmo char Y no PIE 2 → CharServer kicka PIE 1 antes de emitir ticket. S_DISCONNECT no PIE 1 com motivo "logged_in_elsewhere".

Fase 4:

  • npm run world -- set-state <id> ... setar cap=1.
  • Player A entra → ok.
  • Player B entra → fila posição 1.
  • Player A logout → Player B recebe ticket + entra.
  • Restart CharServer durante fila → Player B continua na fila (Streams persistiu).

Fase 5:

  • 2 instâncias npm run dev em portas diferentes + nginx LB.
  • Login → matar node ativo → reconnect cai no outro → sessão preserva (Valkey).
  • kill -TERM no node ativo → drena (sem cortar conexão imediatamente) → desce após timeout.

Fase 6:

  • Subir CharServer SA + CharServer NA + Valkey SA + Valkey NA + read replicas.
  • WorldServer SA claim em CharServer SA: ok.
  • WorldServer SA tentar claim em CharServer NA: 403 region mismatch.
  • Cliente conecta sa.zeusmmo.com.br: vê só mundos SA.

Notas e riscos (após autocrítica)

  • Live Coding ≠ UCLASS nova: cada nova classe C++ no cliente exige rebuild completo + restart do editor. Planejar fazer todas as classes novas de uma fase no mesmo build.
  • Reconnect WSS no cliente: UZeusCharServerSubsystem hoje não tem retry/reconnect. Gap não-trivial — implementar antes da Fase 5 (multi-node faz failover frequente).
  • WorldServer congelado vs morto: heartbeat continua fluindo se game thread trava mas worker thread vive. Liveness probe externa (Fase 6) detecta isso. Fase 1-5 fica como risco aceito.
  • Argon2id no claim: custo 50-200ms por hash. WorldServer faz 1x no boot — irrelevante. Crash-loop hammering → rate limit no endpoint (1/min/IP).
  • Replication lag MySQL: assumido <100ms (verdade em RDS/Aurora). VPS amador pode ser 1-5s. Medir antes da Fase 6.
  • Limite de capacity por mundo: ~500-1500 players com WorldServer monolítico. Acima disso precisa world sharding interno (zone servers) — Fase 8+ se necessário. Documentar como limite atual.
  • Chat global, party, friends: fora desta frente. Entram como serviço paralelo no CharServer (canal A, opcodes 3000+) depois da Fase 3.
  • Anti-cheat client-side (EAC/BattlEye/VAC): decisão comercial pós-launch. Fora de escopo.
  • Area of Interest no WorldServer: indispensável pra >100 players concorrentes. Não bloqueia Fase 1-5 (dev tem 1-10 players). Documentar como pré-requisito de teste de carga.
  • CI/CD multi-region: Fase 6 pressupõe Kubernetes ou equivalente. Decisão de ops, fora desta arquitetura.

O que esta frente entrega vs roadmap completo

Esta frente (Fases 1-3.5): ServerSelect funcional + char por mundo + handoff seguro + SSO + writeback sem corrupção. Suficiente pra dev real com 1-50 players concorrentes em 1 região, 1-5 mundos.

Roadmap longo (Fases 4-7): queue, multi-node, multi-region, A2S público. Cada uma incremental — não quebra as anteriores.

Fora do roadmap (frentes paralelas): chat global/party/friends, anti-cheat client-side, world sharding interno (>1000 players), CI/CD K8s, observability completa (tracing distribuído), disaster recovery automatizado.