# 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
