Files
ZMMO/ARQUITETURA_SERVER_SELECT.md
Mateus Rodrigues a354d9eefc chore(jobs): renomeia display "Aprendiz" -> "Novato"
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>
2026-05-23 21:29:12 -03:00

594 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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`.
```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 <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.