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>
41 KiB
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-mastermas 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:
- O que persiste no MySQL (CharServer) vs o que vive em memória (WorldServer).
- Como classes são definidas (data-driven, não tabela DB).
- Como atributos primários geram derivados (fórmulas espelhando rathena).
- Onde acontece o cálculo (autoridade).
- 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_statsseparada (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:
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 (
HpFactornovo) - ✅ 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):
- Player equipa anel VIT+5 → bônus +50 MaxHP →
max_hp=1500salvo no DB (era 1450 base). - WorldServer crasha.
- Spawn: lê
max_hp=1500do DB. - Recarrega equip do inventory mas o WorldServer "esqueceu" qual era o bônus do anel — só vê 1500.
- 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_): 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):
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.
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_idreferencia direto. - Mudar campo
hp_factoroubonus_statsrebalanceia o jogo retroativamente. OK — RuneScape/PoE/MMOs fazem isso o tempo todo. - Hot-reload: endpoint admin
POST /admin/jobs/reloadrecarregajobs.ymlem 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
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:
characterscontinua flat e inalterado — não háactive_loadout_id. Stats + equip atuais vivem emcharacters. 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_skillsquando a feature entrar) — só askill_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 (
ZeusPersistenceC++): hoje oRepository<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 (
ZeusPersistencev2): estender comRepository<T>::Include<R>()(estilo EF Core). Vale o trabalho quando aparecer a 3ª relação eager (loadouts + inventory + skills + buffs). Adiar até lá.
- Roadmap (
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:
- Stat allocation só faz sentido in-world (precisa de UI de char, contexto de combate).
- WorldServer tem stats derivados em memória — UPDATE de STR força recálculo imediato.
- CharServer não precisa entender game logic de cost (
(s-1)/10+2) — fica isolado. - Padrão rathena: map-server (= WorldServer) faz
pc_statusup(rathena/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
characterscom todos os campos (já especificado noARQUITETURA_SERVER_SELECT.md). jobs.ymlapenas comNovice(Novato) — outras classes ficam pra B.- CharServer carrega
jobs.ymlno boot. C_CHAR_CREATEvalidaclass_id == NOVICEe 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.ymlexpande 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_ALLOCUDP opcode. - Validação cost =
(s-1)/10+2. S_CHAR_STAT_UPDATEpush 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_RESETno 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
- 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. - Stats derivados NUNCA persistidos — sempre recalculados em runtime no WorldServer (
StatusCalc::ComputeAll). - Jobs data-driven via
Server/ZeusCharServer/data/jobs.yml(versionado no git, hot-reloadable).characters.class_idé lookup lógico. - Player nasce Novato (
class_id=NOVICE); especialização via quest in-game (Fase B). - Stat allocation acontece no WorldServer (não CharServer). Validação
cost = (s-1)/10 + 2server-side. - Anti-cheat: toda mudança de estado é server-authoritative. Cliente apenas renderiza.
- Fórmulas espelham rathena (
status_calc_pc_emsrc/map/status.cpp:4996) — adapta valores pra balanceamento Zeus depois. - MaxHP/MaxSP são persistidos como cache (recalcula em level up / equip change, salva pra evitar recalcular no spawn).
- Writeback granular: stat allocation = writeback diferido (junto do checkpoint 60s); level up + job change = writeback imediato.
- Loadouts/build switching (Modelo A — Ragnarok clássico): tabela
character_loadoutsseparada (1:N comcharacters). 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. - Sem
active_loadout_idemcharacters: estado vivo permanece emcharacters(flat). Loadouts são snapshots salvos pra reaplicar sob demanda. - Eager loading: TypeORM já suporta. No
ZeusPersistenceC++ 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):
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) |
(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) |
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):
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.
// 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:
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):
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):
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):
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:
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) com switch case gigante:
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á:
- 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):
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:
- 3 structs:
PersistedStatus(SQL),BaseStatusData(RAM, equip+passivas),BattleStatusData(RAM, +buffs). - Recompute total em camadas 2 e 3 — não delta incremental.
- HP/SP atual preservado em recálculo de base.
- Recursion guard no
RecomputeBase/RecomputeBattle. StatusChangecontainer com expiração por timer; buffs nunca persistem.- Bonus handler table (não switch case).
- 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) - rathena
src/map/status.cpp:status_calc_pc_— recálculo de status (linha 4996) - rathena
src/map/status.cpp:status_base_atk— fórmula de ATK base (linha 2424) - rathena
sql-files/main.sql:209-296— tabelacharflat (main.sql) - rathena
src/map/pc.cpp:pc_statusup— server-side stat allocation ARQUITETURA_SERVER_SELECT.md— schema decharacterse fluxos de criação no contexto de mundos