# 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 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`](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.
