# 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 | `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:** 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 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`. ```sql 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` ```typescript @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](Server/ZeusCharServer/src/database/entities/MMO/Character.entity.ts)) tem só campos básicos (id, account_id, slot, name, class_id, level, exp, posição, appearance). A Fase 1 adiciona: ```sql 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 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`](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}/claim` → `204` + 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 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 ...` 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.