Files
ZMMO/ARQUITETURA_CHARACTER_MODEL.md
Mateus Rodrigues a354d9eefc chore(jobs): renomeia display "Aprendiz" -> "Novato"
DA_Job_Novice DisplayName e docs de arquitetura (3 ARQUITETURA*.md)
sincronizados com a nova terminologia PT-BR. Server (Novice.json +
JobsDatabase.cpp) sera commitado separado no repo do ZeusServerEngine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:29:12 -03:00

892 lines
41 KiB
Markdown

# 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 **Novato** (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=Novato) | 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 (Novato → 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: Novato
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 (Novato)
```
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 Novato. 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 (Novato → 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 Novato (parte da Fase 1 do ServerSelect)
- Schema `characters` com todos os campos (já especificado no `ARQUITETURA_SERVER_SELECT.md`).
- `jobs.yml` apenas com `Novice` (Novato) — 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 — Novato 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 Novato** (`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