# 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` é single-table. **Recomendação imediata:** 2 queries separadas no service de spawn (`Repo.Find(id)` + `Repo.FindWhere(character_id=id)`). Performance idêntica a JOIN para single char. Simples. - **Roadmap (`ZeusPersistence` v2):** estender com `Repository::Include()` (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()` 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_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 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 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` 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