feat(char-spawn): Fase 4 client + UI WBPs paralelo (#2)

Co-authored-by: Mateus Rodrigues <mateuus27@outlook.com>
Co-committed-by: Mateus Rodrigues <mateuus27@outlook.com>
This commit was merged in pull request #2.
This commit is contained in:
2026-05-23 02:16:27 -03:00
committed by Mateuus
parent 2cd8b3b2bb
commit 0e96956a17
317 changed files with 7311 additions and 121 deletions

1
.gitignore vendored
View File

@@ -28,3 +28,4 @@ desktop.ini
# Misc
*.log
*.pdb
.vscode/

11
.ignore Normal file
View File

@@ -0,0 +1,11 @@
/.git
/.vs
/.vscode
/Content
/DerivedDataCache
/Intermediate
/Saved
/Plugins/VisualStudioTools/azure-pipelines
/Plugins/VisualStudioTools/Docs
/Plugins/VisualStudioTools/Intermediate
/Plugins/VisualStudioTools/Scripts

View File

@@ -7,6 +7,10 @@
Última actualização: 2026-05-11.
> **Extensões da arquitetura** (documentos normativos paralelos, escopo específico):
> - [`ARQUITETURA_SERVER_SELECT.md`](ARQUITETURA_SERVER_SELECT.md) — Server Select, lista de mundos, handoff CharServer↔WorldServer, multi-region, queue, Valkey. Roadmap em 7 fases.
> - [`ARQUITETURA_CHARACTER_MODEL.md`](ARQUITETURA_CHARACTER_MODEL.md) — Stats primários (STR/AGI/VIT/INT/DEX/LUK), classes (Aprendiz → especializações), fórmulas de stats derivados (ATK/MATK/DEF/...), `jobs.yml` data-driven, stat allocation server-side. Espelha rathena.
> **Sumário de mudanças (2026-05-11):** adicionada secção de **temas sazonais
> de UI** (`UI/Themes/`), com tutorial §4.7, regra de "no hard ref a textura
> em widget" em §5 e política de activação em §1.10.
@@ -210,7 +214,9 @@ Content/
│ ├── UI/ ← HUD, Login, CharacterSelect, Inventory,
│ │ │ Chat, Party, Guild, Trade, Quest, Map,
│ │ │ Shared, Icons
│ │ ├── Shared/ ← WBPs reutilizáveis (UI_Button_Master…)
│ │ ├── Shared/ ← WBPs reutilizáveis (UI_Button_Master,
│ │ │ UI_Panel_Master, UI_Spinner_Master,
│ │ │ UI_CheckBox_Master, UI_Input_Master)
│ │ ├── FrontEnd/ ← WBP_PrimaryGameLayout, DA_FrontEndScreenSet,
│ │ │ telas refinadas (Boot/Login/… vindas do Forge)
│ │ ├── Fonts/ ← Font_*/FF_* (Cinzel, Rajdhani)
@@ -268,9 +274,16 @@ Source/ZMMO/
│ ├── Network/ ← UZMMOWorldSubsystem
│ └── UI/ ← runtime de UI (não é contrato de dados)
│ ├── ZMMOThemeSubsystem.* ← resolução de tema
│ ├── Widgets/ ← UUIButton_Base, UUIPanel_Base
│ ├── Widgets/ ← UUIButton_Base, UUIPanel_Base,
│ │ UUISpinner_Base, UUICheckBox_Base,
│ │ UUIInput_Base (variantes Box/Outline/
│ │ Underline/Search), UUICommonText_Base
│ │ (EUITextRole → FUIStyle.Text)
│ └── FrontEnd/ ← infra de navegação CommonUI:
│ UUIActivatableScreen_Base,
│ UUIBootScreen_Base (1ª tela),
│ UUILoginScreen_Base (2ª tela),
│ CharServerOpcodes.h (faixa 2000-2099),
│ UUIPrimaryGameLayout_Base,
│ UUIManagerSubsystem (LocalPlayer),
│ UUIFrontEndFlowSubsystem (GameInstance),
@@ -622,6 +635,24 @@ Fluxo geral: **Boot → Connecting → Login → ServerSelect → Lobby → Ente
navegadas por um stack CommonUI **dentro** da tela do Lobby — não são estados
de topo do fluxo.
**Rede do pré-login:** o `UUIFrontEndFlowSubsystem` dirige a conexão ao
**`UZeusCharServerSubsystem`** (WebSocket, `CharServerUrl` de Project Settings,
default `ws://127.0.0.1:7100/zeus`) ao entrar em **Boot** — NÃO o
`UZeusNetworkSubsystem` (UDP, world server, usado só no travel
EnteringWorld→InWorld). A tela Boot (`UUIBootScreen_Base`) observa o
CharServer: libera o botão "Iniciar" no `OnConnected`; o clique chama
`UUIFrontEndFlowSubsystem::RequestEnterLogin()` → estado Login. Tela nunca
segura hard-ref de asset de tema (cores via `FUIStyle`; §5).
**Login:** a tela `UUILoginScreen_Base` autentica pelo MESMO WebSocket do
CharServer enviando `C_CHAR_AUTH_REQUEST` (opcode 2000, ver
`CharServerOpcodes.h`) com um token. Escopo atual = **dev**: token `dev:<n>`
aceito pelo `StubTokenValidator` (senha ignorada no stub). `S_CHAR_AUTH_OK`
→ `RequestEnterServerSelect()` → estado ServerSelect; `S_CHAR_AUTH_REJECT`
→ mensagem de erro na própria tela. Auth real (HTTP `/auth/login` + JWT)
fica para tarefa dedicada. "Voltar" → `RequestBack()` (Login→Boot, CharServer
permanece conectado).
1. Prototipar a tela em HTML/CSS em `Tools/Templates/MMO_Widget/` (ou usar os
templates já existentes).
2. Rodar o Zeus UMG Forge (HTML→UMG). Saída crua: `Content/AutoCreated/<batch>/`.

View File

@@ -0,0 +1,891 @@
# Arquitetura: Character Model — Stats, Jobs, Fórmulas
> **Status:** documento normativo paralelo ao `ARQUITETURA.md`. Define como o modelo de personagem do ZMMO funciona — atributos, classes, derivações, autoridade. Espelha as decisões do `.bases/rathena/rathena-master` mas adaptado ao stack TS + UE5.7 do Zeus.
> **Escopo:** persistência + autoridade + onde tudo é calculado. **NÃO cobre** balanceamento de gameplay (decisão de game design — vai num doc de design separado).
## Context
O ZMMO usa modelo de atributos estilo Ragnarok Online — 6 stats primários que o jogador aloca pontos (STR/AGI/VIT/INT/DEX/LUK), com stats derivados calculados a partir deles + level + classe + equip. Jogador nasce **Aprendiz** (Novice) e especializa em outras classes ao chegar a critérios (job level + quest de mudança).
Este doc fixa:
1. O que persiste no MySQL (CharServer) vs o que vive em memória (WorldServer).
2. Como classes são definidas (data-driven, não tabela DB).
3. Como atributos primários geram derivados (fórmulas espelhando rathena).
4. Onde acontece o cálculo (autoridade).
5. Como fluxos de criação/alocação/level up funcionam end-to-end.
---
## 1. Princípios
### 1.1 Persistência flat — TUDO na tabela `characters`
Padrão da indústria emuladora (rathena, TrinityCore, AzerothCore, MaNGOS). Ver `.bases/rathena/rathena-master/sql-files/main.sql:209-296`.
- ✅ 1 query lê o char completo (spawn, list, transfer).
- ✅ Writeback é 1 UPDATE única.
- ❌ Sem entidade `character_stats` separada (1:1 FK seria JOIN inútil — stats não são reutilizáveis entre chars, é relacionamento estritamente 1:1).
### 1.2 Stats primários persistem; derivados são sempre recalculados
| Categoria | Persiste no MySQL | Recalcula em runtime (WorldServer) |
|---|---|---|
| **Primários** (STR/AGI/VIT/INT/DEX/LUK) | ✅ | — |
| **Progressão** (base_level, base_exp, job_level, job_exp) | ✅ | — |
| **Pool atual** (hp, sp) | ✅ | — |
| **Pool máximo** (max_hp, max_sp) | ✅ (cache) | Recalcula em level up / equip change |
| **Pontos não-gastos** (status_point, skill_point) | ✅ | — |
| **Class id** | ✅ | — |
| **Moeda** (zeny) | ✅ | — |
| **Derivados** (ATK, MATK, DEF, MDEF, hit, flee, crit, aspd) | ❌ NUNCA | ✅ Toda hora via `StatusCalc` |
| **Regen rates** (hp_regen, sp_regen) | ❌ | ✅ |
| **Buffs/debuffs ativos** | ❌ | ✅ (efêmero) |
**Por que NUNCA persistir derivados:** mudou um equip → derivado obsoleto. Aplicou buff → obsoleto. Cresceu de level → obsoleto. Salvar deriva inválida toda hora — bug fest silenciosa. Padrão rathena: `status_calc_pc_` em `.bases/rathena/rathena-master/src/map/status.cpp:4996` recalcula a `struct status_data` inteira a cada evento relevante.
### 1.3 Classes (jobs) são data-driven — arquivo YAML, não tabela DB
Padrão rathena: `db/re/job_stats.yml` carregado no boot. Hot-reloadable.
- ✅ Versionado no git (mudança de balanceamento = commit auditável)
- ✅ Sem SELECT repetido em todo spawn
- ✅ Game designer edita arquivo, não roda SQL
**No Zeus:** `Server/ZeusCharServer/data/jobs.yml` (carregado no boot do WorldServer e do CharServer). `characters.class_id` referencia lógica, não FK física.
### 1.4 Autoridade
| Componente | Quem decide | Quem persiste |
|---|---|---|
| Criar char (stats iniciais, class=Aprendiz) | CharServer (valida) | CharServer → MySQL |
| Stat allocation (gastar `status_point` em STR/etc.) | CharServer **ou** WorldServer | CharServer → MySQL (via writeback) |
| Subir de level / ganhar EXP | WorldServer (kill mob, quest) | WorldServer → writeback → CharServer → MySQL |
| Mudar classe (Aprendiz → Espadachim) | WorldServer (cumpre quest) | WorldServer → writeback → CharServer → MySQL |
| Recalcular ATK/MATK/DEF/etc. | WorldServer (memória, toda hora) | NÃO persiste |
| Casting de skill | WorldServer | NÃO persiste (efêmero) |
| Resultado de skill (HP perdido, item dropado) | WorldServer | WorldServer → writeback |
**Regra:** o estado "definitivo" do char vive no MySQL do CharServer. O WorldServer só **simula** com cópia em memória + writeback periódico (60s) e em eventos críticos (level up, item raro, logout).
---
## 2. Stats primários
### 2.1 Os 6 stats clássicos
| Stat | Nome PT-BR | Influencia diretamente |
|---|---|---|
| **STR** | Força | ATK (físico melee), peso carregável |
| **AGI** | Agilidade | ASPD (velocidade de ataque), Flee (esquiva) |
| **VIT** | Vitalidade | MaxHP, DEF (defesa física), resist status |
| **INT** | Inteligência | MaxSP, MATK (mágico), MDEF |
| **DEX** | Destreza | Hit (precisão), ATK (à distância — arco/arma de DEX), cast time |
| **LUK** | Sorte | CRIT (crítico), Perfect Dodge, drop rate raros |
Persistidos como `SMALLINT UNSIGNED` (0-65535), default 1, cap configurável (rathena default 99 pré-renewal, 130 renewal — Zeus pode escolher).
### 2.2 Stats primários adicionais (opcional, futuro)
Rathena Renewal adiciona 6 "trait stats" para classes 4ª: POW, STA, WIS, SPL, CON, CRT. **Não escopo da Fase 1** — adicionar quando classes 4ª entrarem (Fase de gameplay avançado).
### 2.3 Cost de alocar +1 stat
Padrão rathena: `cost = (stat_atual - 1) / 10 + 2`.
```
str=1 → custo de +1 stat = (0/10)+2 = 2 pontos
str=10 → custo = (9/10)+2 = 2
str=11 → custo = (10/10)+2 = 3
str=20 → custo = (19/10)+2 = 3
str=21 → custo = 4
...
str=99 → custo = (98/10)+2 = 11
```
Validação **SEMPRE server-side**:
```ts
const cost = Math.floor((current - 1) / 10) + 2;
if (char.status_point < cost) reject('NotEnoughPoints');
UPDATE characters SET str = str+1, status_point = status_point - cost;
```
---
## 3. Stats derivados (calculados em runtime no WorldServer)
Lista mínima a implementar. Fórmulas espelham rathena (`status_calc_misc`, `status_calc_pc_sub`, `status_base_atk` em `src/map/status.cpp`).
### 3.1 ATK físico (base, sem equip)
Renewal (Zeus alvo):
```
base_atk = floor(str + (str/10)^2 + dex/5 + luk/3 + base_level/4)
```
Para armas de DEX (arco, instrumento, chicote, armas de fogo): troca STR ↔ DEX no cálculo principal.
Total = `base_atk + weapon_atk + refine_bonus + cards_atk + status_atk_buffs`.
Ref: `.bases/rathena/rathena-master/src/map/status.cpp:2424` (`status_base_atk`).
### 3.2 MATK (mágico)
```
matk_min = floor(int + int/7^2 + dex/5 + luk/3 + base_level/4)
matk_max = floor(int + int/5^2 + dex/5 + luk/3 + base_level/4)
```
### 3.3 DEF (defesa física)
```
def2 = floor(vit + (vit/2)^2/30)
def_total = def2 + def_from_equip + def_from_refine
```
Renewal usa "soft DEF + hard DEF" — definir convenção no roadmap.
### 3.4 MDEF (defesa mágica)
```
mdef2 = floor(int + vit/5 + dex/5)
mdef_total = mdef2 + mdef_from_equip
```
### 3.5 HIT (precisão)
```
hit = base_level + dex + luk/3 + skill_bonus + equip_bonus
```
### 3.6 FLEE (esquiva)
```
flee = base_level + agi + luk/5 + skill_bonus + equip_bonus
```
### 3.7 CRIT (crítico)
```
crit = 1 + luk/3 + skill_bonus + equip_bonus
```
(Em rathena multiplica por 10 internamente para resolução; converte na display.)
### 3.8 ASPD (velocidade de ataque)
```
aspd = base_aspd_da_arma - (agi + dex/4) * fator_de_classe
```
`base_aspd_da_arma` vem do `jobs.yml` (cada job tem array `BaseASPD` por tipo de arma).
### 3.9 MaxHP / MaxSP — três camadas (cuidado para não persistir bônus)
Esta é a parte mais delicada. **Há três camadas** de MaxHP/MaxSP, cada uma com regra diferente:
| Camada | Onde vive | Quando recalcula | Persiste no DB? |
|---|---|---|---|
| **1. Base** | `characters.max_hp` / `max_sp` (cache) | Level up, job change, stat allocation (VIT/INT permanentes) | ✅ Sim |
| **2. Bônus de equipamento** (anel VIT+5, armor MaxHP+200) | WorldServer em memória | Equip / desequip de item | ❌ Nunca — recalcula do `character_inventory` no spawn |
| **3. Bônus de buff** (skill "Endure" +100 MaxHP por 60s) | WorldServer em memória (`status_change`) | Buff start / expire | ❌ Nunca — efêmero, expira sozinho |
**MaxHP efetivo (mostrado pro player) = camada 1 + camada 2 + camada 3.** Sempre calculado em runtime, nunca persistido.
**Fórmula da camada 1 (base):**
```
max_hp_base = floor(jobs.yml[class].HpFactor * base_level + jobs.yml[class].HpIncrease) * (1 + vit_base/100)
max_sp_base = floor(jobs.yml[class].SpFactor * base_level + jobs.yml[class].SpIncrease) * (1 + int_base/100)
```
Note: usa `vit_base` (`characters.vit`), **não** `vit_efetivo` (com bônus de equip). Bônus de equip não recalcula o cache base — entra como camada 2 separada.
**Triggers que escrevem `characters.max_hp` (camada 1):**
- ✅ Level up (`base_level += 1`)
- ✅ Job change (`HpFactor` novo)
- ✅ Stat allocation permanente (`vit_base += 1`)
**Triggers que NÃO escrevem `characters.max_hp` (só atualizam camadas 2/3 em memória):**
- ❌ Equip de item com VIT+5 ou MaxHP+200
- ❌ Desequip de item
- ❌ Buff aplicado (skill, food, gravação)
- ❌ Buff expirou
**Por que essa separação importa (cenário de bug se misturar):**
Suponha que você persistisse `max_hp` total (base + equip + buff):
1. Player equipa anel VIT+5 → bônus +50 MaxHP → `max_hp=1500` salvo no DB (era 1450 base).
2. WorldServer crasha.
3. Spawn: lê `max_hp=1500` do DB.
4. Recarrega equip do inventory mas o WorldServer "esqueceu" qual era o bônus do anel — só vê 1500.
5. Player desequipa o anel → bug: continua com 1500.
Pior com buff: writeback periódico salva `max_hp` durante buff ativo → buff expira mas DB tem o valor inflado → spawn dá HP zombie.
**Padrão rathena** ([src/map/status.cpp:status_calc_pc_](.bases/rathena/rathena-master/src/map/status.cpp)): salva só camada 1. Camadas 2 e 3 são sempre recalculadas do equip + buffs ativos no spawn. Buffs não persistem (morrem no logout).
**Estrutura em memória no WorldServer (template):**
```cpp
struct CharRuntimeStatus {
// --- Camada 1 (do DB; persistido) ---
uint32 max_hp_base;
uint32 max_sp_base;
uint16 str_base, agi_base, vit_base, int_base, dex_base, luk_base;
// --- Camada 2 (do inventory; não persiste) ---
int32 equip_max_hp_bonus, equip_max_sp_bonus;
int16 equip_str_bonus, equip_agi_bonus, /*...*/ equip_luk_bonus;
// --- Camada 3 (de status_change ativos; não persiste) ---
int32 buff_max_hp_bonus, buff_max_sp_bonus;
int16 buff_str_bonus, /*...*/;
// --- Computados on-demand (camada 1 + 2 + 3) ---
uint32 EffectiveMaxHp() const { return max_hp_base + equip_max_hp_bonus + buff_max_hp_bonus; }
uint16 EffectiveVit() const { return vit_base + equip_vit_bonus + buff_vit_bonus; }
// ... análogo pros outros stats
};
```
Cliente sempre vê o efetivo (`S_CHAR_HP_UPDATE { effective_hp, effective_max_hp }`). Cliente nunca vê o base nem os bônus separados — só a soma final.
---
## 4. Schema `jobs.yml`
Arquivo: `Server/ZeusCharServer/data/jobs.yml`.
```yaml
version: 1
jobs:
- id: 0
name: Novice # nome técnico (inglês, estável)
display_name_ptbr: Aprendiz
parent_job: null
max_base_level: 99
max_job_level: 10
hp_factor: 35 # rathena Novice: ~35
hp_increase: 0
sp_factor: 10
sp_increase: 0
max_weight: 20000
base_aspd:
bare: 2000
dagger: 1900
sword: 2000
starting_hp: 40
starting_sp: 11
starting_status_points: 0
starting_skill_points: 0
allowed_weapons: [bare, dagger]
skills:
- id: NV_BASIC
max_level: 10
- id: NV_FIRST_AID
max_level: 1
- id: 1
name: Swordman
display_name_ptbr: Espadachim
parent_job: Novice # precisa ter sido Novice antes
requirements:
base_level: 1 # 1 (na verdade qualquer; Ragnarok exige job_level 10 Novice — definir)
job_level: 10 # do Novice
quest: "swordman_test" # quest id no questdb
max_base_level: 99
max_job_level: 50
hp_factor: 70
hp_increase: 200
sp_factor: 20
sp_increase: 200
bonus_stats: # auto-stats ao subir job level
- job_level: 2
str: 1
- job_level: 6
vit: 1
# ...
allowed_weapons: [bare, dagger, sword, two_hand_sword, axe]
skills:
- id: SM_BASH
max_level: 10
# ...
# Mago, Arqueiro, Mercador, Ladrão, Acólito...
```
**Notas:**
- IDs estáveis (não mude) — `characters.class_id` referencia direto.
- Mudar campo `hp_factor` ou `bonus_stats` rebalanceia o jogo retroativamente. **OK** — RuneScape/PoE/MMOs fazem isso o tempo todo.
- Hot-reload: endpoint admin `POST /admin/jobs/reload` recarrega `jobs.yml` em runtime sem restart (Fase 6+; Fase 1 só load no boot).
---
## 5. Fluxos
### 5.1 Criação de personagem (Aprendiz)
```
Cliente → C_CHAR_CREATE { name, world_id, class_id=NOVICE, appearance }
CharServer:
1. Validar (name único globalmente, world_id existe, slot disponível, class_id=NOVICE — não pode criar direto em outra classe)
2. Lookup jobs.yml[NOVICE] → { starting_hp, starting_sp, hp_factor, sp_factor, ... }
3. INSERT characters (
account_id, world_id, slot, name, class_id=NOVICE,
base_level=1, base_exp=0, job_level=1, job_exp=0,
str=1, agi=1, vit=1, int=1, dex=1, luk=1,
hp=starting_hp, max_hp=starting_hp, sp=starting_sp, max_sp=starting_sp,
status_point=0, skill_point=0,
zeny=0, appearance,
map_name="zmmo_starting_village", pos_x=..., pos_y=..., pos_z=..., yaw_deg=0
)
4. → S_CHAR_CREATE_OK
```
**Nota:** o player **sempre** nasce Aprendiz. Outras classes só via job change in-game (Fase pós-Fase 3).
### 5.2 Stat allocation (após level up)
```
Cliente → C_CHAR_STAT_ALLOC { stat: "str", amount: 1 }
CharServer (ou WorldServer; ver §6 — autoridade):
1. Lookup char no cache/DB
2. cost = floor((char.str - 1) / 10) + 2
3. amount * cost ≤ char.status_point? else REJECT('NotEnoughPoints')
4. char.str + amount > MAX_STAT(jobs.yml[class].max_stat)? else REJECT('StatCapped')
5. UPDATE characters SET str = str + 1, status_point = status_point - cost, version = version + 1
6. WorldServer: status_calc_pc(char) → recalcula derivados → manda S_CHAR_STAT_UPDATE pro cliente
```
### 5.3 Level up
```
WorldServer (em kill mob / complete quest):
1. char.base_exp += mob.exp_reward
2. enquanto char.base_exp >= exp_table[char.base_level + 1]:
char.base_exp -= exp_table[char.base_level + 1]
char.base_level += 1
char.status_point += status_per_level(char.base_level) // ex.: 3 base + level/4
max_hp = recalc(char, jobs.yml)
max_sp = recalc(char, jobs.yml)
char.hp = max_hp // heal full no level up (padrão Ragnarok)
char.sp = max_sp
3. POST /interserver/characters/{id}/checkpoint (writeback imediato — level up é crítico)
4. Enviar S_CHAR_LEVEL_UP pro cliente
```
Mesmo padrão pra job_exp/job_level (com skill_point em vez de status_point).
### 5.4 Job change (Aprendiz → Espadachim, etc.)
```
WorldServer (player cumpriu quest):
1. Validar requirements: jobs.yml[Swordman].requirements.{base_level, job_level, quest}
2. char.class_id = SWORDMAN
3. char.job_level = 1
4. char.job_exp = 0
5. (Opcional Ragnarok-style: status_point NÃO zera; player mantém stats alocados)
6. recalc max_hp/max_sp (novo HpFactor/SpFactor)
7. POST /interserver/characters/{id}/checkpoint
8. S_CHAR_JOB_CHANGE
```
### 5.5 Logout — writeback final
```
WorldServer:
POST /interserver/characters/{id}/checkpoint {
str, agi, vit, int, dex, luk,
base_level, base_exp, job_level, job_exp,
hp, max_hp, sp, max_sp,
status_point, skill_point, zeny,
map_name, pos_x, pos_y, pos_z, yaw_deg,
appearance,
version: current_version
}
```
Conflict (409) → recarrega + merge + retry.
---
## 5.6 Loadouts (build switching) — Modelo A: equip + skills, stats permanentes
### Premissa
**Stats primários (str/agi/vit/int/dex/luk) são permanentes** uma vez alocados — padrão Ragnarok clássico. Permitir trocar stats via loadout livre destruiria o significado de cada ponto investido.
Loadouts no ZMMO guardam **apenas equip e skills** — exatamente o padrão FFXIV Gearset / WoW Equipment Manager. Cobre o caso de uso real (alternar entre setup PvE/PvP/farm sem reequipar manualmente todos os slots), sem virar free respec.
### Stat reset (separado de loadouts)
Reset de stats é uma operação **rara e cara**, fora do sistema de loadouts. Caminhos previstos (a decidir no game design):
- Item raro "Reset Stone" (drop de boss / cash shop).
- NPC específico que cobra moeda (zeny alto ou moeda especial).
- Privilégio VIP (limite mensal).
Mecânica: zera `str/agi/vit/int/dex/luk` aos valores base do job, e converte tudo investido em `status_point` (player realoca do zero). WorldServer endpoint dedicado, audit log obrigatório.
### Schema `character_loadouts`
```sql
CREATE TABLE character_loadouts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
character_id BIGINT UNSIGNED NOT NULL,
slot_index TINYINT UNSIGNED NOT NULL, -- 0..N-1 (N = MAX_LOADOUTS, ex.: 10)
name VARCHAR(32) NOT NULL, -- "Tank", "DPS PvE", "PvP Build"
-- Snapshot de equip (referência a instâncias específicas no inventory,
-- FK pra character_inventory.id que entra em frente futura).
equip_head_id BIGINT UNSIGNED NULL,
equip_body_id BIGINT UNSIGNED NULL,
equip_weapon_id BIGINT UNSIGNED NULL,
equip_shield_id BIGINT UNSIGNED NULL,
equip_garment_id BIGINT UNSIGNED NULL,
equip_footgear_id BIGINT UNSIGNED NULL,
equip_acc_left_id BIGINT UNSIGNED NULL,
equip_acc_right_id BIGINT UNSIGNED NULL,
equip_head_top_id BIGINT UNSIGNED NULL,
equip_head_mid_id BIGINT UNSIGNED NULL,
equip_head_bottom_id BIGINT UNSIGNED NULL,
-- (Slots espelham o padrão rathena: weapon, shield, garment, footgear,
-- acc esquerda, acc direita, head top/mid/bottom)
-- Snapshot de hotbar de skills (JSON: { slot_index: skill_id })
skill_hotbar JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE,
UNIQUE KEY uniq_char_slot (character_id, slot_index),
INDEX idx_loadouts_character (character_id)
);
```
**Notas:**
- **`characters` continua flat e inalterado** — não há `active_loadout_id`. Stats + equip atuais vivem em `characters`. Loadouts são *snapshots salvos*, não "estado ativo".
- Não inclui stats — modelo A. Loadout não é respec.
- Não inclui skills aprendidas (essas ficam em `character_skills` quando a feature entrar) — só a `skill_hotbar` (quais skills estão nas teclas de atalho).
- Referências de equip apontam pra **instâncias específicas** do item (`character_inventory.id`), não pra item template id genérico. Se o player vendeu o item depois de salvar a build, o slot vira NULL na hora de aplicar + warning UI.
### Fluxos de loadout
**Salvar build atual como preset:**
```
Cliente → C_LOADOUT_SAVE { slot_index, name }
WorldServer:
Pega equip atual + skill_hotbar do char em memória
INSERT/UPDATE character_loadouts SET ...
POST /interserver/characters/{id}/loadouts/save (writeback imediato)
S_LOADOUT_SAVED { slot_index }
```
**Aplicar loadout (trocar equip + hotbar):**
```
Cliente → C_LOADOUT_APPLY { slot_index }
WorldServer:
SELECT * FROM character_loadouts WHERE character_id=$id AND slot_index=$slot
Para cada equip_*_id no loadout:
Verifica se item ainda existe em character_inventory + pertence ao char
Se sim: equipa
Se não: slot vira NULL + retorna warning lista de slots "perdidos"
Aplica skill_hotbar
Recalcula stats derivados (StatusCalc)
S_LOADOUT_APPLIED { applied_slot, missing_slots? }
```
**Listar loadouts (CharSelect ou in-world):**
```
Cliente → C_LOADOUT_LIST
WorldServer:
SELECT slot_index, name FROM character_loadouts WHERE character_id=$id ORDER BY slot_index
S_LOADOUT_LIST [{ slot_index, name, summary }]
```
**Deletar loadout:**
```
Cliente → C_LOADOUT_DELETE { slot_index }
DELETE FROM character_loadouts WHERE character_id=$id AND slot_index=$slot
```
### Comportamento na seleção de char (CharSelect)
Aplicar loadout só funciona **in-world**, não na CharSelect — porque depende do inventory carregado, contexto de combate, etc. CharSelect só lista qual foi a última usada (estado UX no cliente). WorldServer aplica o loadout ativo automaticamente no spawn se cliente indicou via parâmetro do C_CHAR_SELECT (opcional — pode também simplesmente carregar o último equip salvo em `characters` direto).
### Autoridade
Mesmo princípio dos stats: **WorldServer é dono**. Cliente envia comando, WorldServer valida + aplica + persiste via canal C.
### Eager loading no spawn (resposta à sua dúvida específica)
**Pergunta original:** "ao pegar Character retorna também Stats — fica mais fácil".
Como decidimos modelo A (stats permanentes em `characters`, sem entidade separada), **não há eager loading de stats** — já vêm na mesma row.
Para loadouts:
- **CharServer (TypeORM):** anota relacionamento com `{ eager: true }` ou faz `.relations(['loadouts'])` em queries específicas. Suporte nativo.
- **WorldServer (`ZeusPersistence` C++):** hoje o `Repository<T>` é single-table. **Recomendação imediata:** 2 queries separadas no service de spawn (`Repo<Character>.Find(id)` + `Repo<CharacterLoadout>.FindWhere(character_id=id)`). Performance idêntica a JOIN para single char. Simples.
- **Roadmap (`ZeusPersistence` v2):** estender com `Repository<T>::Include<R>()` (estilo EF Core). Vale o trabalho quando aparecer a 3ª relação eager (loadouts + inventory + skills + buffs). Adiar até lá.
### Roadmap
Loadouts entram em **frente própria de gameplay**, depois das Fases A-C do Character Model e da feature de inventory. Não bloqueia Fase 1 do ServerSelect — schema é puro additive (não muda `characters`).
---
## 6. Autoridade — onde stat allocation acontece?
Duas escolhas defensáveis:
| Onde | Vantagens | Desvantagens |
|---|---|---|
| **CharServer** (recebe `C_CHAR_STAT_ALLOC` direto do cliente) | Simples; muda DB direto sem RPC | Cliente in-world tem que avisar WorldServer "stats mudaram" → recalcular |
| **WorldServer** (cliente sempre fala com mundo) | WorldServer já tem char na memória, recalcula imediato | Stat allocation só funciona logged in (sem alocação fora do mundo) |
**Recomendação:** **WorldServer.** Razões:
1. Stat allocation só faz sentido in-world (precisa de UI de char, contexto de combate).
2. WorldServer tem stats derivados em memória — UPDATE de STR força recálculo imediato.
3. CharServer não precisa entender game logic de cost (`(s-1)/10+2`) — fica isolado.
4. Padrão rathena: map-server (= WorldServer) faz `pc_statusup` ([rathena/src/map/pc.cpp](.bases/rathena/rathena-master/src/map/pc.cpp)).
Fluxo: cliente envia opcode UDP pro WorldServer → valida + UPDATE em memória + writeback diferido (não imediato — junto com o próximo checkpoint periódico de 60s). Level up é exceção (writeback imediato).
---
## 7. Anti-cheat e validação server-side
**Toda** mudança de stat passa por validação no servidor:
- Cliente envia `C_CHAR_STAT_ALLOC { stat, amount }` → servidor valida + faz, nunca cliente diz "novo valor".
- HP/SP nunca vêm do cliente — só do WorldServer (autoridade).
- EXP é WorldServer who-says.
- Zeny: transações sempre server-side (trade, NPC shop, drop).
Cliente apenas **renderiza** o estado que o servidor empurra (`S_CHAR_STAT_UPDATE`, `S_CHAR_HP_UPDATE`, etc.).
Audit log entries (Fase 3 do ServerSelect doc): toda mudança de zeny>1000, level up, mudança de classe, item raro recebido → `POST /interserver/audit`.
---
## 8. Roadmap de implementação
### Fase A — Schema + criação de char Aprendiz (parte da Fase 1 do ServerSelect)
- Schema `characters` com todos os campos (já especificado no `ARQUITETURA_SERVER_SELECT.md`).
- `jobs.yml` apenas com `Novice` (Aprendiz) — outras classes ficam pra B.
- CharServer carrega `jobs.yml` no boot.
- `C_CHAR_CREATE` valida `class_id == NOVICE` e usa defaults do yml.
- Cliente UI: tela de criação com nome + appearance (sem stat allocation aqui — Aprendiz nasce com todos os stats=1).
### Fase B — Classes adicionais + job change
- `jobs.yml` expande para Espadachim, Mago, Arqueiro, Mercador, Ladrão, Acólito (6 first jobs clássicos do Ragnarok).
- Quest de mudança de classe (mínimo: NPC simples; quest system real fica pra C).
- WorldServer endpoint pra mudar class.
### Fase C — Stat allocation + status_point/skill_point granting
- WorldServer recebe `C_CHAR_STAT_ALLOC` UDP opcode.
- Validação cost = `(s-1)/10+2`.
- `S_CHAR_STAT_UPDATE` push pro cliente.
- Skill point granting tied to job_level up.
### Fase D — Stats derivados completos + StatusCalc framework
- Implementar `StatusCalc::ComputeAll(char, equip, buffs)` no WorldServer C++.
- ATK/MATK/DEF/MDEF/hit/flee/crit/aspd.
- Push de update no equip change, buff aplicado, level up.
### Fase E — Classes 2ª (advanced) e além
- Especialização (Knight, Wizard, Hunter, etc. — segunda promoção do Ragnarok).
- Eventualmente classes 3ª (Renewal) e trait stats (POW/STA/WIS/SPL/CON/CRT).
### Fase F — Loadouts (build switching, Modelo A)
**Pré-requisito:** inventory implementado (FK de `character_loadouts.equip_*_id` aponta pra `character_inventory.id`).
- Schema `character_loadouts` (já especificado em §5.6).
- WorldServer endpoints: `C_LOADOUT_SAVE/APPLY/LIST/DELETE`.
- Cliente UI: tela "Builds" com até N slots, botões salvar/aplicar/renomear/deletar, preview de equip.
- Audit log em apply (Fase 3 do ServerSelect doc).
### Fase G — Stat reset (item / NPC / VIP)
- Endpoint `C_CHAR_STAT_RESET` no WorldServer.
- Validação: tem o item de reset? Tem o zeny do NPC? Tem privilégio VIP + cooldown?
- Lógica: zera str/agi/vit/int/dex/luk pros base do job; converte total investido em `status_point`.
- Audit log obrigatório (write-heavy, sensível a abuso).
**A se conecta com a Fase 1 do ServerSelect.** B-G são frentes próprias de gameplay, fora do escopo de network/handoff.
---
## 9. Decisões consolidadas
1. **Stats primários flat** na tabela `characters` (str/agi/vit/int/dex/luk + level/exp + hp/sp/max_hp/max_sp + status_point/skill_point + zeny + class_id). **Sem entidade separada.**
2. **Stats derivados NUNCA persistidos** — sempre recalculados em runtime no WorldServer (`StatusCalc::ComputeAll`).
3. **Jobs data-driven** via `Server/ZeusCharServer/data/jobs.yml` (versionado no git, hot-reloadable). `characters.class_id` é lookup lógico.
4. **Player nasce Aprendiz** (`class_id=NOVICE`); especialização via quest in-game (Fase B).
5. **Stat allocation acontece no WorldServer** (não CharServer). Validação `cost = (s-1)/10 + 2` server-side.
6. **Anti-cheat:** toda mudança de estado é server-authoritative. Cliente apenas renderiza.
7. **Fórmulas espelham rathena** (`status_calc_pc_` em `src/map/status.cpp:4996`) — adapta valores pra balanceamento Zeus depois.
8. **MaxHP/MaxSP são persistidos como cache** (recalcula em level up / equip change, salva pra evitar recalcular no spawn).
9. **Writeback granular:** stat allocation = writeback diferido (junto do checkpoint 60s); level up + job change = writeback imediato.
10. **Loadouts/build switching (Modelo A — Ragnarok clássico):** tabela `character_loadouts` separada (1:N com `characters`). Guarda **apenas equip + skill_hotbar**, NÃO stats. Stats permanecem permanentes; reset só via item raro / NPC / VIP (operação dedicada, fora do sistema de loadouts). Aplicar loadout = trocar equip e hotbar, server-side, in-world only.
11. **Sem `active_loadout_id` em `characters`:** estado vivo permanece em `characters` (flat). Loadouts são snapshots salvos pra reaplicar sob demanda.
12. **Eager loading:** TypeORM já suporta. No `ZeusPersistence` C++ usa 2 queries separadas por enquanto; `Include<R>()` fluent fica como roadmap quando houver 3+ relações eager.
---
## 10. Lições do rathena — o que adotar e o que evitar
Análise direta do código do rathena (versão master atual em `.bases/rathena/rathena-master/`). Lista o que vale levar pro modelo moderno do Zeus e o que é legado a evitar.
### 10.1 Três estruturas de status, não duas
Rathena tem **3 structs separadas** convivendo em `map_session_data` ([src/map/pc.hpp:382-388](.bases/rathena/rathena-master/src/map/pc.hpp)):
```cpp
class map_session_data : public block_list {
...
struct mmo_charstatus status; // (1) PERSISTED — SQL <-> RAM
struct status_data base_status; // (2) BASE — RAM only
struct status_data battle_status; // (3) BATTLE — RAM only
status_change sc; // (Buffs/debuffs ativos)
...
};
```
| Camada | Conteúdo | Persiste no DB? | Recalcula quando |
|---|---|---|---|
| **(1) `mmo_charstatus status`** (PERSISTED) | Stats primários permanentes, level/exp, HP/SP atuais, equip slots, skill levels aprendidos, zeny, pos | ✅ Sim — espelha SQL `char` table | Em writeback periódico (a partir do battle_status) |
| **(2) `status_data base_status`** (BASE) | Stats primários + bônus de equip + bônus de skills passivas. ATK/MATK/DEF/etc. SEM buffs ativos. | ❌ Não | Equip change, level up, mudança de skill, mudança de job, stat allocation. Via `status_calc_pc_` ([src/map/status.cpp:4996](.bases/rathena/rathena-master/src/map/status.cpp)) |
| **(3) `status_data battle_status`** (BATTLE) | base_status **+** todos os buffs/debuffs ativos (`status_change`). É o valor "final" usado em combat e mostrado pro cliente. | ❌ Não | Cada vez que um buff aplica/expira, cada hit (debuffs proc). Via `status_calc_bl_main` ([src/map/status.cpp:5836](.bases/rathena/rathena-master/src/map/status.cpp)) |
**Por que três e não duas:** separar equip+passivas (camada 2, muda raro) de buffs (camada 3, muda toda hora) permite recalcular APENAS camada 3 quando um buff entra/sai. Sem isso, todo buff força recálculo completo de equipamento — caro em PvP/raids com muitos buffs simultâneos.
**Adaptação Zeus C++ (WorldServer):**
```cpp
struct CharRuntimeState {
PersistedStatus persisted; // copia do MySQL via ZeusPersistence (camada 1)
BaseStatusData base_status; // calculado de persisted + equip + passivas (camada 2)
BaseStatusData battle_status; // base_status + status_change (camada 3)
StatusChangeContainer sc; // lista de buffs ativos com expire_tick
};
```
`battle_status` é o que vai pro `S_CHAR_STAT_UPDATE` pro cliente. Cliente nunca vê `base_status` nem `persisted` direto.
### 10.2 Recompute total > delta incremental
Rathena **zera `base_status` inteiro** (`memset` em status.cpp:3816) e recompila tudo do zero a cada `status_calc_pc_`. Não tenta "subtrair o equip antigo, somar o novo" — pull-not-push.
```cpp
// rathena status.cpp:3816 (simplificado)
memset(&base_status->max_hp, 0, sizeof(struct status_data) - sizeof(hp/sp/ap));
// reaplica defaults do job
// reaplica equipped items
// reaplica skills passivas
// recalcula derivados
```
**Por que:** delta incremental cria drift sutil. Um bug de "esqueci subtrair na hora de desequipar" e o stat fica inflado pra sempre. Recompute total é O(N) por evento mas N é pequeno (~10 slots de equip + ~30 skills passivas) e elimina bug class inteira.
**Adotar:** mesma estratégia no `WorldServer::StatusCalc::RecomputeBase(char)`. Em C++ moderno usar `base_status = BaseStatusData{};` (move-assign do default) em vez de `memset` (porque structs com membros não-trivially-copyable quebram).
### 10.3 HP/SP atual nunca é zerado no recálculo
Truque clássico do rathena (status.cpp:3816): o `memset` começa em `&base_status->max_hp`, **pulando** os primeiros membros (`hp`, `sp`, `ap`). HP atual sobrevive ao recálculo de base_status.
**Razão:** equipar um anel não pode resetar o HP do char pra zero. Mas pode mudar `max_hp` — se `max_hp` aumentou, HP continua o atual; se diminuiu, capa em `min(hp, max_hp)`.
**Adotar:** ordenar membros do struct em C++ pra ter HP/SP/AP no topo, e adotar regra `RecomputeBase()` sem tocar nesses 3. Ou explicitamente preservar:
```cpp
const auto saved_hp = base_status.hp, saved_sp = base_status.sp;
base_status = BaseStatusData{};
base_status.hp = saved_hp; base_status.sp = saved_sp;
ApplyJobDefaults(); ApplyEquip(); ApplyPassives(); ComputeDerived();
base_status.hp = std::min(base_status.hp, base_status.max_hp); // cap
```
### 10.4 Recursion guard obrigatório no recálculo
Rathena ([status.cpp:3756-3764](.bases/rathena/rathena-master/src/map/status.cpp)):
```cpp
static int32 calculating = 0;
if (++calculating > 10) return -1;
// ... recálculo
--calculating;
```
**Razão:** recálculo de stat pode trigger skill que aplica buff que dispara recálculo. Sem guard = stack overflow em corner cases (combos de auto-cast, item triggers cascading).
**Adotar:** flag thread-local `bool recalculating = false` ou contador. Se já está em recálculo, retornar erro/skip em vez de reentrar. Importante mesmo em arquiteturas modernas — buffs reativos a hits criam loops circulares fácil.
### 10.5 Recalc parcial via bitflags (otimização avançada)
Rathena tem ([status.hpp](.bases/rathena/rathena-master/src/map/status.hpp)):
```cpp
enum scb_flag {
SCB_NONE = 0, SCB_BASE, SCB_STR, SCB_AGI, /*...*/, SCB_WATK, SCB_BATK,
SCB_MAX
};
status_calc_bl_main(bl, std::bitset<SCB_MAX>{ SCB_STR | SCB_WATK });
```
Quando só STR muda (e nada mais), recalcula apenas WATK derivado (que depende de STR). Performance no PvP de alto nível.
**Adotar:** **NÃO na Fase 1**. Implementar como otimização depois quando profile mostrar `RecomputeBase` aparecendo no top do flame chart. Pra MMOs com <1000 players concurrent, recompute total é OK.
### 10.6 Buffs em estrutura separada com expiração por timer
Rathena `status_change` ([src/map/status.hpp](.bases/rathena/rathena-master/src/map/status.hpp)):
```cpp
struct status_change_entry {
int32 timer; // handle do scheduler
int32 val1-4; // payload (varia por tipo)
t_tick tick; // duração total
};
struct status_change {
status_change_entry *data[SC_MAX]; // indexado por SCType
};
```
Cada SC (status change) tem timer próprio que dispara `status_change_end` quando expira. Engine de timer global gerencia. **Buffs NUNCA persistem em SQL** — morrem em logout (regra mais comum) ou DC (alguns servers preservam alguns SC em rejoin rápido).
**Adotar:**
```cpp
struct StatusChangeEntry {
StatusChangeType type;
int32 stack_value; // SC ID + payload
TickT expire_at; // absolute deadline
};
class StatusChangeContainer {
std::unordered_map<StatusChangeType, StatusChangeEntry> active;
void Tick(TickT now) { /* expira os terminados, dispara recalc se necessário */ }
};
```
Buffs morrem em logout — padrão da indústria. Exceções (food buffs, gravações permanentes, equip-while-stationary) tratadas via cases especiais no game design depois.
### 10.7 Item bonus data-driven, não hardcoded
Rathena tem `pc_bonus` ([src/map/pc.cpp](.bases/rathena/rathena-master/src/map/pc.cpp)) com switch case gigante:
```cpp
void pc_bonus(map_session_data *sd, int32 type, int32 val) {
switch (type) {
case SP_STR: sd->base_status.str += val; break;
case SP_AGI: sd->base_status.agi += val; break;
case SP_MAXHP: sd->base_status.max_hp += val; break;
// ... ~200 cases
}
}
```
E `item_db.yml` declara em YAML quais bonuses cada item dá:
```yaml
- Id: 1109
Name: Sword
Type: Weapon
Script: |
bonus bAtk, 25;
bonus bStr, 1;
```
**O bom:** items são data-driven (designer edita YAML, não código).
**O ruim:** switch case com 200 cases é horror de manter. Bug em um case afeta um stat só, difícil de testar.
**Adotar (versão moderna):**
```cpp
using BonusHandler = void(*)(BaseStatusData&, int32 val);
inline const std::unordered_map<BonusType, BonusHandler> kBonusHandlers = {
{ BonusType::Str, [](auto& s, int32 v){ s.str += v; } },
{ BonusType::Agi, [](auto& s, int32 v){ s.agi += v; } },
{ BonusType::MaxHp, [](auto& s, int32 v){ s.max_hp += v; } },
// ...
};
void ApplyBonus(BaseStatusData& s, BonusType type, int32 val) {
if (auto it = kBonusHandlers.find(type); it != kBonusHandlers.end()) {
it->second(s, val);
}
}
```
Mesma capacidade data-driven, sem switch gigante. Testes unitários ficam triviais (testa cada handler isolado).
### 10.8 Save state separado do battle state
Rathena periodicamente faz `pc_makesavestatus(sd)` que **copia** `sd->battle_status.hp/sp/ap``sd->status.hp/sp/ap`. Só depois isso vai pro CharServer/SQL.
**Razão:** `battle_status` tem cap inflado por buff (max_hp +500 de buff). `status.hp` deve refletir o valor real que faz sentido persistir (HP atual, capa de `status.max_hp` que é o base puro sem buff).
**Adotar:** writeback NUNCA salva valores afetados por buff ativo. Save HP é o `min(battle.hp, base.max_hp)`. Save MaxHP é `base.max_hp`. Garante que log out + log in com buff expirado não dá HP zombie.
### 10.9 O que NÃO levar do rathena
Práticas que são legado e dão mais trabalho do que valem em projeto novo:
| Anti-pattern | Por que evitar | Alternativa moderna |
|---|---|---|
| **`battle_config` global com 500+ flags** | Estado mutável global, difícil de testar, conflito em multi-tenant | Struct `BalanceConfig` injetada por DI; YAML de balance versionado |
| **Arrays C fixos (`MAX_SKILL`, `MAX_INVENTORY`)** | Crash se exceder; memória desperdiçada se vazio | `std::vector` ou `std::array` com bounds-check em debug |
| **`map_session_data : block_list`** (herda) | Mistura "dados do char" com "ator com posição no mundo" | Separar `CharacterData` (POD) de `WorldActor` (componente de posição/movimento) |
| **Job class em bitfield** (`JOBL_BABY`, `MAPID_SUMMONER`) | Decisão de packet do RO original. Manutenção horrível | `enum class JobId` + `struct JobTraits { bool is_baby; bool can_summon; }` data-driven em `jobs.yml` |
| **Recursive call preemption por contador** | Workaround pra event model mal-formado | ECS / actor model não-reentrante; quando reentrância é necessária, fila de eventos diferida |
| **`pc_calc_skilltree` muta skill tree do char** | Mistura "skills aprendidas" (persistente) com "efeitos passivos ativos" (derivado) | Separar `LearnedSkills` (no DB) de `ActivePassives` (calculado em memória) |
| **`status_change->data[SC_MAX]` array linkedlist** | Lookup linear, memória pré-alocada | `std::unordered_map<SCType, SCEntry>` ou `boost::flat_map` para hot path |
| **Hot-reload via `@reloadbattleconf`** in-game commands | Não tem versionamento, vira PvP-zoeira "ajustei config no live" | Hot-reload via endpoint admin autenticado + audit log obrigatório |
| **SQL queries em string puro** (sem ORM) no map server | SQL injection era real; manutenção horrível | ZeusPersistence (já fizemos) ou TypeORM no CharServer |
| **Globals com função inicializadora (`do_init_status`)** | Side effects no static init | Singleton com lifetime explícito, `StatusEngine engine; engine.Init(config);` |
### 10.10 Resumo do que vai pro Zeus
**Adotar:**
1. **3 structs**: `PersistedStatus` (SQL), `BaseStatusData` (RAM, equip+passivas), `BattleStatusData` (RAM, +buffs).
2. **Recompute total** em camadas 2 e 3 — não delta incremental.
3. **HP/SP atual preservado** em recálculo de base.
4. **Recursion guard** no `RecomputeBase`/`RecomputeBattle`.
5. **`StatusChange` container** com expiração por timer; buffs nunca persistem.
6. **Bonus handler table** (não switch case).
7. **Save state separado** do battle state no writeback.
**Adiar (otimização):**
- Recalc parcial via bitflags (vale com >1000 concurrent).
**Evitar:**
- battle_config global; arrays C fixos; herança block_list; job em bits; SQL crú no map server; hot-reload sem audit.
---
## 11. Não cobertos aqui
Por design, estes temas têm docs próprios (futuros):
- **Inventory e items** (slots, refine, cards, sockets) — `ARQUITETURA_INVENTORY.md` (a criar).
- **Skill system** (skill tree, cooldowns, animations) — `ARQUITETURA_SKILLS.md` (a criar).
- **Quests** — `ARQUITETURA_QUESTS.md` (a criar).
- **Combat resolution** (damage formula, element table, race modifier) — `ARQUITETURA_COMBAT.md` (a criar).
- **Game balance** (números concretos: quanto STR dá quanto ATK em level N) — `DESIGN_BALANCE.md` (decisão de game design, não arquitetura).
- **Party/guild** — `ARQUITETURA_SOCIAL.md` (a criar).
Este documento só fixa **estrutura e autoridade** — o que vai onde, quem decide o quê.
---
## Referências
- rathena `db/re/job_stats.yml` — schema base de jobs ([.bases/rathena/rathena-master/db/re/job_stats.yml](Server/.bases/rathena/rathena-master/db/re/job_stats.yml))
- rathena `src/map/status.cpp:status_calc_pc_` — recálculo de status ([linha 4996](Server/.bases/rathena/rathena-master/src/map/status.cpp))
- rathena `src/map/status.cpp:status_base_atk` — fórmula de ATK base ([linha 2424](Server/.bases/rathena/rathena-master/src/map/status.cpp))
- rathena `sql-files/main.sql:209-296` — tabela `char` flat ([main.sql](Server/.bases/rathena/rathena-master/sql-files/main.sql))
- rathena `src/map/pc.cpp:pc_statusup` — server-side stat allocation
- [`ARQUITETURA_SERVER_SELECT.md`](ARQUITETURA_SERVER_SELECT.md) — schema de `characters` e fluxos de criação no contexto de mundos

View File

@@ -0,0 +1,593 @@
# 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.

View File

@@ -29,3 +29,8 @@ UIStyleTable=/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles
; Mapa estado->tela. O DA já existe (RootLayoutClass = WBP_PrimaryGameLayout);
; StateScreens fica vazio até os WBPs de página virem do Zeus UMG Forge (§4.8).
ScreenSetAsset=/Game/ZMMO/UI/FrontEnd/DA_FrontEndScreenSet.DA_FrontEndScreenSet
; Fase 4 — char spawn DB-driven. DT_Maps espelha o `maps_config.json` do
; server (Server/ZeusServerEngine/Config/DataTables/maps_config.json) — o
; cliente resolve `mapId -> ClientLevel/spawns` via FZMMOMapDef rows.
MapsTableAsset=/Game/ZMMO/Data/World/DT_Maps.DT_Maps

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More