DA_Job_Novice DisplayName e docs de arquitetura (3 ARQUITETURA*.md) sincronizados com a nova terminologia PT-BR. Server (Novice.json + JobsDatabase.cpp) sera commitado separado no repo do ZeusServerEngine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
32 KiB
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 emServer/) — é 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/worldPortnoS_CHAR_SELECT_OKestá hardcoded em127.0.0.1:27777.handoffTokené gerado aleatório no CharServer mas nada valida no WorldServer.- Não existe tabela
worldsno schema; não existe opcode pra listar mundos. charactersnão temworld_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 | UZeusNetworkSubsystem ↔ ZeusNet/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:
- WorldServer fala apenas com CharServer da sua região (
CharServerEndpointnoserver.json). - Cliente conecta no DNS regional via GeoDNS. Não cruza região durante sessão.
- MySQL writes globais (primary central); reads na replica regional.
- Valkey é por-região (estado vivo é local — sem cross-region sync).
- Lista de mundos é regional por default (padrão FFXIV — cliente vê só os mundos da
account.region). ZEUS_REGIONenv var em cada CharServer node;regionnoworlds.regiondeve 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:
- Admin via CLI:
npm run world -- create --name=Aurora --host=... --port=... --region=br --cap=500→ CharServer geraworld_id(UUID) +world_secret(32 bytes base64url). Output mostra os dois uma vez.secret_hash(Argon2id) é persistido emworlds.secret_hash. - Admin copia
world_id+world_secretproConfig/server.jsondo WorldServer. - WorldServer no boot:
POST /interserver/worlds/{id}/claim { secret }. CharServer valida Argon2id contrasecret_hash+ region match. Sucesso →state=online. Falha →403+ WorldServer aborta startup. - WorldServer começa heartbeat (Fase 1 HTTP; Fase 2+ Valkey direto).
- WorldServer crashado → TTL Valkey expira em 30s →
state=offlineautomá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 terworld_id.- Estrutura flat segue rathena (
.bases/rathena/rathena-master/sql-files/main.sql:209-296) — padrão da indústria emuladora. Sem entidadecharacter_statsseparada (1:1 FK seria JOIN inútil). - Defaults vêm da row Novato 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 Novato → 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 porZEUS_REGION).- Estender
C_CHAR_LIST_REQUESTcomworld_id. - Estender
C_CHAR_CREATEcomworld_idobrigató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 emworlds.last_heartbeat_atou tabelaworlds_liveauxiliar).
S_CHAR_SELECTvalidachar.world_id == world.ideworld.state == online. Reject reasons novos:WorldOffline=8,WorldMaintenance=9,WorldFull=10,WorldRegionMismatch=11.
- Devolve
worldHost/Portdo mundo escolhido (não mais hardcoded).
Cliente Unreal:
- Novo estado
EZMMOFrontEndState::CharSelectentre ServerSelect e ingame. UUIServerSelectScreen_Base: pede lista, popula cards, clique →SelectedWorldIdnoUUIFrontEndFlowSubsystem+ transição CharSelect.UUICharSelectScreen_Base(NOVO): lista filtrada porSelectedWorldId, 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(addworld_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.tsServer/ZeusCharServer/scripts/world-cli.ts(NOVO)Server/ZeusCharServer/src/protocol/CharOpcodes.tsClients/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.ymlcomvalkey/valkey:7-alpine.ioredisno CharServer (API 100% compatível com Valkey 7.2).- Cliente Valkey C++ no WorldServer (
hiredis— C, popular; oucpp_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
HSETcompleto +PEXPIRE 30000.
- On player_connected/disconnected:
WorldIdvalidado contraworldsno boot via HTTP claim (Fase 1).
CharServer (TS):
ValkeyServicesingleton (ioredis client + subscriber).WorldService.getLiveStatus(id)lê do Valkey (fallbackstate=offlinese key não existe).- Novo opcode
S_WORLD_STATUS_UPDATE=2062— push pra cliente quando recebeworld:events. S_WORLD_LISTagora incluipop/queue_len/statedo Valkey no payload inicial.
Cliente:
- ServerSelect: subscribe na ativação; listener
S_WORLD_STATUS_UPDATEatualiza 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_OKenvia 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_SELECTcom pop cheio:ZADD queue:world:{id} {ts_ms} {account_id}+ respondeS_CHAR_QUEUED(position, ETA). - Worker push 5s:
S_QUEUE_UPDATEpra cada um na fila (com posição viaZRANK). - Consumer da stream
world:slot-free:{id}:ZPOPMINda queue → emite ticket →S_CHAR_QUEUE_READY(ticket, worldHost, worldPort). - Cleanup de zombie entries: WS disconnect →
ZREMdo 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 viaPUBLISH session:revoke.- Health endpoint pro LB; readiness probe diferenciado.
- Connection draining: SIGTERM handler seta
draining=trueflag, 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
CharServerEndpointaponta pro hostname regional. - Worlds filtrados por região automaticamente (
WHERE region = $ZEUS_REGION). - Read-your-own-writes: flag
READ_PRIMARY=1no 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) comkidheader. - Liveness probe externa (não só heartbeat self-reported): CharServer faz GET
/healthno 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_portnoserver.json(default off em dev/CI). - Ref: https://developer.valvesoftware.com/wiki/Server_queries
Decisões consolidadas
- CharServer dono dos
characters(MySQL global). Colunaworld_id(FK) +version(optimistic locking). - WorldServer stateless quanto a persistência. Cache em memória + writeback ~60s.
- Lista de mundos:
worldsno MySQL (durável). Estado vivo (pop/queue/state) em Valkey (Fase 2+). - Heartbeat WorldServer: três triggers combinados (event + state-change + periodic 5s) escrevendo direto no Valkey (Fase 2+); HTTP POST como fallback na Fase 1.
- Mundo em manutenção: sempre acessível em ServerSelect/CharSelect; botão "Entrar" rejeita.
- Char list filtrada por mundo: escolha do mundo na ServerSelect → CharSelect com chars desse mundo. Criação inclui
world_id. - Ticket de handoff: opaque 32-byte token, TTL 10s,
GETDELno WorldServer, com reserva de slot (INCR world:{id}:reserved). - SSO enforcement:
char:active:{char_id}em Valkey + kick old session via canal C antes de emitir ticket novo. - Concurrency control: coluna
versionemcharacters, optimistic UPDATE no writeback (409 → retry com merge). - 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. - Valkey (fork BSD do Redis 7.2 mantido pela Linux Foundation): a partir da Fase 2.
ioredisno Node funciona sem mudança. Dockervalkey/valkey:7-alpine. world_id: admin-create + WorldServer-claim com Argon2id secret (padrão AccelByte Armada). Rate-limited (anti crash-loop).- Topologia geo-distribuída: Valkey + CharServer por região; MySQL primary global + read replicas regionais. Multi-region é Fase 6.
- QueryPort: UDP A2S público apenas pra visibilidade externa (Steam/Battlemetrics) na Fase 7. Estado pro cliente do jogo sempre via WSS/CharServer.
- mTLS interserver: Fase 6 (substitui shared secret pra cross-region).
- JWT key rotation: JWKS endpoint na Fase 6.
- 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→ imprimeworld_id+world_secret.npm run world -- create --name="Volcanus" --host=127.0.0.1 --port=27778 --region=na --cap=500.npm run world -- listmostra ambos comstate=offline.- WorldServer com
world_id+world_secretnoConfig/server.jsonsobe →POST /interserver/worlds/{id}/claim→204+ log "claimed".stateno DB viraonline. - 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 → rejectWorldMaintenance(CharSelect ainda funciona).
Fase 2:
docker-compose up -d valkeysobe Valkey.valkey-cli HGETALL world:{id}:statusmostra 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 charactersmostra 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_DISCONNECTno PIE 1 com motivo "logged_in_elsewhere".
Fase 4:
npm run world -- set-state <id> ...setarcap=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 devem portas diferentes + nginx LB. - Login → matar node ativo → reconnect cai no outro → sessão preserva (Valkey).
kill -TERMno 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:
UZeusCharServerSubsystemhoje 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.