Files
ZMMO/ARQUITETURA_CHARACTER_MODEL.md
2026-05-23 02:16:27 -03:00

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-master mas adaptado ao stack TS + UE5.7 do Zeus.

Escopo: persistência + autoridade + onde tudo é calculado. NÃO cobre balanceamento de gameplay (decisão de game design — vai num doc de design separado).

Context

O ZMMO usa modelo de atributos estilo Ragnarok Online — 6 stats primários que o jogador aloca pontos (STR/AGI/VIT/INT/DEX/LUK), com stats derivados calculados a partir deles + level + classe + equip. Jogador nasce Aprendiz (Novice) e especializa em outras classes ao chegar a critérios (job level + quest de mudança).

Este doc fixa:

  1. O que persiste no MySQL (CharServer) vs o que vive em memória (WorldServer).
  2. Como classes são definidas (data-driven, não tabela DB).
  3. Como atributos primários geram derivados (fórmulas espelhando rathena).
  4. Onde acontece o cálculo (autoridade).
  5. Como fluxos de criação/alocação/level up funcionam end-to-end.

1. Princípios

1.1 Persistência flat — TUDO na tabela characters

Padrão da indústria emuladora (rathena, TrinityCore, AzerothCore, MaNGOS). Ver .bases/rathena/rathena-master/sql-files/main.sql:209-296.

  • 1 query lê o char completo (spawn, list, transfer).
  • Writeback é 1 UPDATE única.
  • Sem entidade character_stats separada (1:1 FK seria JOIN inútil — stats não são reutilizáveis entre chars, é relacionamento estritamente 1:1).

1.2 Stats primários persistem; derivados são sempre recalculados

Categoria Persiste no MySQL Recalcula em runtime (WorldServer)
Primários (STR/AGI/VIT/INT/DEX/LUK)
Progressão (base_level, base_exp, job_level, job_exp)
Pool atual (hp, sp)
Pool máximo (max_hp, max_sp) (cache) Recalcula em level up / equip change
Pontos não-gastos (status_point, skill_point)
Class id
Moeda (zeny)
Derivados (ATK, MATK, DEF, MDEF, hit, flee, crit, aspd) NUNCA Toda hora via StatusCalc
Regen rates (hp_regen, sp_regen)
Buffs/debuffs ativos (efêmero)

Por que NUNCA persistir derivados: mudou um equip → derivado obsoleto. Aplicou buff → obsoleto. Cresceu de level → obsoleto. Salvar deriva inválida toda hora — bug fest silenciosa. Padrão rathena: status_calc_pc_ em .bases/rathena/rathena-master/src/map/status.cpp:4996 recalcula a struct status_data inteira a cada evento relevante.

1.3 Classes (jobs) são data-driven — arquivo YAML, não tabela DB

Padrão rathena: db/re/job_stats.yml carregado no boot. Hot-reloadable.

  • Versionado no git (mudança de balanceamento = commit auditável)
  • Sem SELECT repetido em todo spawn
  • Game designer edita arquivo, não roda SQL

No Zeus: Server/ZeusCharServer/data/jobs.yml (carregado no boot do WorldServer e do CharServer). characters.class_id referencia lógica, não FK física.

1.4 Autoridade

Componente Quem decide Quem persiste
Criar char (stats iniciais, class=Aprendiz) CharServer (valida) CharServer → MySQL
Stat allocation (gastar status_point em STR/etc.) CharServer ou WorldServer CharServer → MySQL (via writeback)
Subir de level / ganhar EXP WorldServer (kill mob, quest) WorldServer → writeback → CharServer → MySQL
Mudar classe (Aprendiz → Espadachim) WorldServer (cumpre quest) WorldServer → writeback → CharServer → MySQL
Recalcular ATK/MATK/DEF/etc. WorldServer (memória, toda hora) NÃO persiste
Casting de skill WorldServer NÃO persiste (efêmero)
Resultado de skill (HP perdido, item dropado) WorldServer WorldServer → writeback

Regra: o estado "definitivo" do char vive no MySQL do CharServer. O WorldServer só simula com cópia em memória + writeback periódico (60s) e em eventos críticos (level up, item raro, logout).


2. Stats primários

2.1 Os 6 stats clássicos

Stat Nome PT-BR Influencia diretamente
STR Força ATK (físico melee), peso carregável
AGI Agilidade ASPD (velocidade de ataque), Flee (esquiva)
VIT Vitalidade MaxHP, DEF (defesa física), resist status
INT Inteligência MaxSP, MATK (mágico), MDEF
DEX Destreza Hit (precisão), ATK (à distância — arco/arma de DEX), cast time
LUK Sorte CRIT (crítico), Perfect Dodge, drop rate raros

Persistidos como SMALLINT UNSIGNED (0-65535), default 1, cap configurável (rathena default 99 pré-renewal, 130 renewal — Zeus pode escolher).

2.2 Stats primários adicionais (opcional, futuro)

Rathena Renewal adiciona 6 "trait stats" para classes 4ª: POW, STA, WIS, SPL, CON, CRT. Não escopo da Fase 1 — adicionar quando classes 4ª entrarem (Fase de gameplay avançado).

2.3 Cost de alocar +1 stat

Padrão rathena: cost = (stat_atual - 1) / 10 + 2.

str=1 → custo de +1 stat = (0/10)+2 = 2 pontos
str=10 → custo = (9/10)+2 = 2
str=11 → custo = (10/10)+2 = 3
str=20 → custo = (19/10)+2 = 3
str=21 → custo = 4
...
str=99 → custo = (98/10)+2 = 11

Validação SEMPRE server-side:

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_): 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: Aprendiz
    parent_job: null
    max_base_level: 99
    max_job_level: 10
    hp_factor: 35              # rathena Novice: ~35
    hp_increase: 0
    sp_factor: 10
    sp_increase: 0
    max_weight: 20000
    base_aspd:
      bare: 2000
      dagger: 1900
      sword: 2000
    starting_hp: 40
    starting_sp: 11
    starting_status_points: 0
    starting_skill_points: 0
    allowed_weapons: [bare, dagger]
    skills:
      - id: NV_BASIC
        max_level: 10
      - id: NV_FIRST_AID
        max_level: 1

  - id: 1
    name: Swordman
    display_name_ptbr: Espadachim
    parent_job: Novice         # precisa ter sido Novice antes
    requirements:
      base_level: 1            # 1 (na verdade qualquer; Ragnarok exige job_level 10 Novice — definir)
      job_level: 10            # do Novice
      quest: "swordman_test"   # quest id no questdb
    max_base_level: 99
    max_job_level: 50
    hp_factor: 70
    hp_increase: 200
    sp_factor: 20
    sp_increase: 200
    bonus_stats:               # auto-stats ao subir job level
      - job_level: 2
        str: 1
      - job_level: 6
        vit: 1
      # ...
    allowed_weapons: [bare, dagger, sword, two_hand_sword, axe]
    skills:
      - id: SM_BASH
        max_level: 10
      # ...

  # Mago, Arqueiro, Mercador, Ladrão, Acólito...

Notas:

  • IDs estáveis (não mude) — characters.class_id referencia direto.
  • Mudar campo hp_factor ou bonus_stats rebalanceia o jogo retroativamente. OK — RuneScape/PoE/MMOs fazem isso o tempo todo.
  • Hot-reload: endpoint admin POST /admin/jobs/reload recarrega jobs.yml em runtime sem restart (Fase 6+; Fase 1 só load no boot).

5. Fluxos

5.1 Criação de personagem (Aprendiz)

Cliente → C_CHAR_CREATE { name, world_id, class_id=NOVICE, appearance }

CharServer:
  1. Validar (name único globalmente, world_id existe, slot disponível, class_id=NOVICE — não pode criar direto em outra classe)
  2. Lookup jobs.yml[NOVICE] → { starting_hp, starting_sp, hp_factor, sp_factor, ... }
  3. INSERT characters (
       account_id, world_id, slot, name, class_id=NOVICE,
       base_level=1, base_exp=0, job_level=1, job_exp=0,
       str=1, agi=1, vit=1, int=1, dex=1, luk=1,
       hp=starting_hp, max_hp=starting_hp, sp=starting_sp, max_sp=starting_sp,
       status_point=0, skill_point=0,
       zeny=0, appearance,
       map_name="zmmo_starting_village", pos_x=..., pos_y=..., pos_z=..., yaw_deg=0
     )
  4. → S_CHAR_CREATE_OK

Nota: o player sempre nasce Aprendiz. Outras classes só via job change in-game (Fase pós-Fase 3).

5.2 Stat allocation (após level up)

Cliente → C_CHAR_STAT_ALLOC { stat: "str", amount: 1 }

CharServer (ou WorldServer; ver §6 — autoridade):
  1. Lookup char no cache/DB
  2. cost = floor((char.str - 1) / 10) + 2
  3. amount * cost ≤ char.status_point? else REJECT('NotEnoughPoints')
  4. char.str + amount > MAX_STAT(jobs.yml[class].max_stat)? else REJECT('StatCapped')
  5. UPDATE characters SET str = str + 1, status_point = status_point - cost, version = version + 1
  6. WorldServer: status_calc_pc(char) → recalcula derivados → manda S_CHAR_STAT_UPDATE pro cliente

5.3 Level up

WorldServer (em kill mob / complete quest):
  1. char.base_exp += mob.exp_reward
  2. enquanto char.base_exp >= exp_table[char.base_level + 1]:
       char.base_exp -= exp_table[char.base_level + 1]
       char.base_level += 1
       char.status_point += status_per_level(char.base_level)  // ex.: 3 base + level/4
       max_hp = recalc(char, jobs.yml)
       max_sp = recalc(char, jobs.yml)
       char.hp = max_hp   // heal full no level up (padrão Ragnarok)
       char.sp = max_sp
  3. POST /interserver/characters/{id}/checkpoint  (writeback imediato — level up é crítico)
  4. Enviar S_CHAR_LEVEL_UP pro cliente

Mesmo padrão pra job_exp/job_level (com skill_point em vez de status_point).

5.4 Job change (Aprendiz → Espadachim, etc.)

WorldServer (player cumpriu quest):
  1. Validar requirements: jobs.yml[Swordman].requirements.{base_level, job_level, quest}
  2. char.class_id = SWORDMAN
  3. char.job_level = 1
  4. char.job_exp = 0
  5. (Opcional Ragnarok-style: status_point NÃO zera; player mantém stats alocados)
  6. recalc max_hp/max_sp (novo HpFactor/SpFactor)
  7. POST /interserver/characters/{id}/checkpoint
  8. S_CHAR_JOB_CHANGE

5.5 Logout — writeback final

WorldServer:
  POST /interserver/characters/{id}/checkpoint {
    str, agi, vit, int, dex, luk,
    base_level, base_exp, job_level, job_exp,
    hp, max_hp, sp, max_sp,
    status_point, skill_point, zeny,
    map_name, pos_x, pos_y, pos_z, yaw_deg,
    appearance,
    version: current_version
  }

Conflict (409) → recarrega + merge + retry.


5.6 Loadouts (build switching) — Modelo A: equip + skills, stats permanentes

Premissa

Stats primários (str/agi/vit/int/dex/luk) são permanentes uma vez alocados — padrão Ragnarok clássico. Permitir trocar stats via loadout livre destruiria o significado de cada ponto investido.

Loadouts no ZMMO guardam apenas equip e skills — exatamente o padrão FFXIV Gearset / WoW Equipment Manager. Cobre o caso de uso real (alternar entre setup PvE/PvP/farm sem reequipar manualmente todos os slots), sem virar free respec.

Stat reset (separado de loadouts)

Reset de stats é uma operação rara e cara, fora do sistema de loadouts. Caminhos previstos (a decidir no game design):

  • Item raro "Reset Stone" (drop de boss / cash shop).
  • NPC específico que cobra moeda (zeny alto ou moeda especial).
  • Privilégio VIP (limite mensal).

Mecânica: zera str/agi/vit/int/dex/luk aos valores base do job, e converte tudo investido em status_point (player realoca do zero). WorldServer endpoint dedicado, audit log obrigatório.

Schema character_loadouts

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).

Fluxo: cliente envia opcode UDP pro WorldServer → valida + UPDATE em memória + writeback diferido (não imediato — junto com o próximo checkpoint periódico de 60s). Level up é exceção (writeback imediato).


7. Anti-cheat e validação server-side

Toda mudança de stat passa por validação no servidor:

  • Cliente envia C_CHAR_STAT_ALLOC { stat, amount } → servidor valida + faz, nunca cliente diz "novo valor".
  • HP/SP nunca vêm do cliente — só do WorldServer (autoridade).
  • EXP é WorldServer who-says.
  • Zeny: transações sempre server-side (trade, NPC shop, drop).

Cliente apenas renderiza o estado que o servidor empurra (S_CHAR_STAT_UPDATE, S_CHAR_HP_UPDATE, etc.).

Audit log entries (Fase 3 do ServerSelect doc): toda mudança de zeny>1000, level up, mudança de classe, item raro recebido → POST /interserver/audit.


8. Roadmap de implementação

Fase A — Schema + criação de char Aprendiz (parte da Fase 1 do ServerSelect)

  • Schema characters com todos os campos (já especificado no ARQUITETURA_SERVER_SELECT.md).
  • jobs.yml apenas com Novice (Aprendiz) — outras classes ficam pra B.
  • CharServer carrega jobs.yml no boot.
  • C_CHAR_CREATE valida class_id == NOVICE e usa defaults do yml.
  • Cliente UI: tela de criação com nome + appearance (sem stat allocation aqui — Aprendiz nasce com todos os stats=1).

Fase B — Classes adicionais + job change

  • jobs.yml expande para Espadachim, Mago, Arqueiro, Mercador, Ladrão, Acólito (6 first jobs clássicos do Ragnarok).
  • Quest de mudança de classe (mínimo: NPC simples; quest system real fica pra C).
  • WorldServer endpoint pra mudar class.

Fase C — Stat allocation + status_point/skill_point granting

  • WorldServer recebe C_CHAR_STAT_ALLOC UDP opcode.
  • Validação cost = (s-1)/10+2.
  • S_CHAR_STAT_UPDATE push pro cliente.
  • Skill point granting tied to job_level up.

Fase D — Stats derivados completos + StatusCalc framework

  • Implementar StatusCalc::ComputeAll(char, equip, buffs) no WorldServer C++.
  • ATK/MATK/DEF/MDEF/hit/flee/crit/aspd.
  • Push de update no equip change, buff aplicado, level up.

Fase E — Classes 2ª (advanced) e além

  • Especialização (Knight, Wizard, Hunter, etc. — segunda promoção do Ragnarok).
  • Eventualmente classes 3ª (Renewal) e trait stats (POW/STA/WIS/SPL/CON/CRT).

Fase F — Loadouts (build switching, Modelo A)

Pré-requisito: inventory implementado (FK de character_loadouts.equip_*_id aponta pra character_inventory.id).

  • Schema character_loadouts (já especificado em §5.6).
  • WorldServer endpoints: C_LOADOUT_SAVE/APPLY/LIST/DELETE.
  • Cliente UI: tela "Builds" com até N slots, botões salvar/aplicar/renomear/deletar, preview de equip.
  • Audit log em apply (Fase 3 do ServerSelect doc).

Fase G — Stat reset (item / NPC / VIP)

  • Endpoint C_CHAR_STAT_RESET no WorldServer.
  • Validação: tem o item de reset? Tem o zeny do NPC? Tem privilégio VIP + cooldown?
  • Lógica: zera str/agi/vit/int/dex/luk pros base do job; converte total investido em status_point.
  • Audit log obrigatório (write-heavy, sensível a abuso).

A se conecta com a Fase 1 do ServerSelect. B-G são frentes próprias de gameplay, fora do escopo de network/handoff.


9. Decisões consolidadas

  1. Stats primários flat na tabela characters (str/agi/vit/int/dex/luk + level/exp + hp/sp/max_hp/max_sp + status_point/skill_point + zeny + class_id). Sem entidade separada.
  2. Stats derivados NUNCA persistidos — sempre recalculados em runtime no WorldServer (StatusCalc::ComputeAll).
  3. Jobs data-driven via Server/ZeusCharServer/data/jobs.yml (versionado no git, hot-reloadable). characters.class_id é lookup lógico.
  4. Player nasce Aprendiz (class_id=NOVICE); especialização via quest in-game (Fase B).
  5. Stat allocation acontece no WorldServer (não CharServer). Validação cost = (s-1)/10 + 2 server-side.
  6. Anti-cheat: toda mudança de estado é server-authoritative. Cliente apenas renderiza.
  7. Fórmulas espelham rathena (status_calc_pc_ em src/map/status.cpp:4996) — adapta valores pra balanceamento Zeus depois.
  8. MaxHP/MaxSP são persistidos como cache (recalcula em level up / equip change, salva pra evitar recalcular no spawn).
  9. Writeback granular: stat allocation = writeback diferido (junto do checkpoint 60s); level up + job change = writeback imediato.
  10. Loadouts/build switching (Modelo A — Ragnarok clássico): tabela character_loadouts separada (1:N com characters). Guarda apenas equip + skill_hotbar, NÃO stats. Stats permanecem permanentes; reset só via item raro / NPC / VIP (operação dedicada, fora do sistema de loadouts). Aplicar loadout = trocar equip e hotbar, server-side, in-world only.
  11. Sem active_loadout_id em characters: estado vivo permanece em characters (flat). Loadouts são snapshots salvos pra reaplicar sob demanda.
  12. Eager loading: TypeORM já suporta. No ZeusPersistence C++ usa 2 queries separadas por enquanto; Include<R>() fluent fica como roadmap quando houver 3+ relações eager.

10. Lições do rathena — o que adotar e o que evitar

Análise direta do código do rathena (versão master atual em .bases/rathena/rathena-master/). Lista o que vale levar pro modelo moderno do Zeus e o que é legado a evitar.

10.1 Três estruturas de status, não duas

Rathena tem 3 structs separadas convivendo em map_session_data (src/map/pc.hpp:382-388):

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/apsd->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).
  • QuestsARQUITETURA_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/guildARQUITETURA_SOCIAL.md (a criar).

Este documento só fixa estrutura e autoridade — o que vai onde, quem decide o quê.


Referências