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>
892 lines
41 KiB
Markdown
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
|