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>
594 lines
32 KiB
Markdown
594 lines
32 KiB
Markdown
# 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.
|