/* global React, window, Kw, Parsed */ /* ============================================================ CLASS CREATOR — aba "Classes" do livro. Substitui quando chapter.id === "classes". Esquerda = config (classe + nível + info) Direita = picks de magia, nível a nível Regra crítica: budget_by_level vem da API. Picks NÃO acumulam. Cada slot só permite os available_circles do seu nível. ============================================================ */ const { useState, useEffect, useMemo, useRef } = React; /* ----- helper: debounce ----- */ /* ------------------------------------------------------------ Pré-requisitos de atributo ------------------------------------------------------------ */ function useAttrTotals() { const [totals, setTotals] = useState( () => (window.CHAR_ATTRS && window.CHAR_ATTRS.getState().totals) || {} ); useEffect(() => { if (!window.CHAR_ATTRS) return undefined; return window.CHAR_ATTRS.subscribe((s) => setTotals(s.totals)); }, []); return totals; } function meetsAttrPrereqs(power, totals) { const parsed = power?.prerequisites_parsed?.attrs; if (!parsed || Object.keys(parsed).length === 0) return { ok: true, missing: [] }; const missing = []; for (const [attr, min] of Object.entries(parsed)) { if ((totals?.[attr] ?? -99) < min) missing.push({ attr, min }); } return { ok: missing.length === 0, missing }; } function fmtPrereqLabel(missing) { return missing .map((m) => `${m.attr} ${m.min >= 0 ? "+" : ""}${m.min}`) .join(", "); } function useDebounced(value, ms) { const [v, setV] = useState(value); useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]); return v; } /* ----- helper: extrai o nome da divindade do nome de um Poder Concedido ----- Dois formatos suportados: "Nome do Poder (Deus)" → "Deus" "Escolhido de Deus" → "Deus" Retorna null se o formato não bater. */ function getPowerDeity(powerName) { if (!powerName) return null; const m1 = powerName.match(/^Escolhido\s+de\s+(.+?)\s*$/i); if (m1) return m1[1].trim(); const m2 = powerName.match(/\(([^)]+)\)\s*$/); if (m2) return m2[1].trim(); return null; } /* ----- helper: tag entre parênteses no fim do nome do poder ----- */ function getParenTag(powerName) { if (!powerName) return null; const m = powerName.match(/\(([^)]+)\)\s*$/); return m ? m[1].trim() : null; } /* ----- helper: normaliza string para comparação case- e accent-insensitive ----- */ function normTxt(s) { if (!s) return ""; return s.trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); } /* ----- helper: tag de raça do poder bate com a raça salva? ----- Formatos suportados na tag: "Anão" → nome simples "Anão, Goblin, Hobgoblin" → vírgula = OU "Sereia/Tritão" → barra = OU "Suraggel - Aggelus" → hífen = name + variant "Moreau do Lobo" → preposição = name + variant "Qareen da Luz" → idem "Naidora[Elfo-do-Céu]" → colchetes são notas, ignorar "Várias" → wildcard, aceita qualquer raça Para Moreau, "do Lobo" precisa bater com variant "Herança do Lobo" — por isso comparamos com `.includes`. */ function raceTagMatchesPart(part, savedRace) { let clean = part.replace(/\s*\[[^\]]*\]/g, "").trim(); if (!clean) return false; /* "Sereia/Tritão" — OU entre alternativas */ if (clean.includes("/")) { return clean.split("/").some((s) => raceTagMatchesPart(s.trim(), savedRace)); } /* "Suraggel - Aggelus" (hífen) ou "Moreau do Lobo" (preposição) */ const hyphenMatch = clean.match(/^(.+?)\s+-\s+(.+)$/); const prepMatch = clean.match(/^(.+?)\s+d[oae]s?\s+(.+)$/i); const compound = hyphenMatch || prepMatch; if (compound) { const [, name, tail] = compound; if (normTxt(name) !== normTxt(savedRace.name)) return false; /* a cauda precisa estar contida na variant salva (ex: "Lobo" ⊂ "Herança do Lobo") */ return !!savedRace.variant && normTxt(savedRace.variant).includes(normTxt(tail)); } /* nome simples */ return normTxt(clean) === normTxt(savedRace.name); } function raceTagMatches(tag, savedRace) { if (!tag || !savedRace?.name) return false; if (/^v[áa]rias$/i.test(tag.trim())) return true; /* "Várias" = wildcard */ return tag.split(/\s*,\s*/).some((part) => raceTagMatchesPart(part, savedRace)); } /* ----- helper: filtra poderes restritos (concedido + de raça) ----- - "Poder Concedido" → só para classes is_devoted + divindade salva - "Poder de Raça" → só para a raça correspondente Poderes sem tag entre parênteses ficam visíveis (fallback seguro). */ /* ----- helper: bate uma regra restricted_to contra o estado salvo ----- */ function ruleMatches(rule, ctx) { if (!rule) return true; /* OR composto */ if (Array.isArray(rule.any_of)) { return rule.any_of.some((sub) => ruleMatches(sub, ctx)); } /* raça */ if (rule.race) { if (!ctx.savedRace?.name) return false; if (normTxt(rule.race) !== normTxt(ctx.savedRace.name)) return false; } /* classe + path + lineage (qualquer entry de classe deve casar) */ if (rule.class || rule.path || rule.lineage) { const match = ctx.classEntries.some((e) => { if (rule.class && normTxt(e.className) !== normTxt(rule.class)) return false; if (rule.path && normTxt(e.path) !== normTxt(rule.path)) return false; if (rule.lineage && normTxt(e.lineage) !== normTxt(rule.lineage)) return false; return true; }); if (!match) return false; } return true; } function filterRestrictedPowers(powers, classData) { if (!Array.isArray(powers)) return powers; const isDevoted = !!classData?.is_devoted; let savedDeityName = null; let savedRace = null; let classEntries = []; try { if (isDevoted) { const d = JSON.parse(localStorage.getItem("arton_char_deity")); savedDeityName = d?.name || null; } savedRace = JSON.parse(localStorage.getItem("arton_char_race")); const charData = JSON.parse(localStorage.getItem("arton_char_class")); classEntries = Array.isArray(charData?.classes) ? charData.classes : []; } catch {} const ctx = { savedRace, classEntries }; /* feiticeiro + linhagem dracônica? — usado para Poder Dracônico */ const hasDraconicSorcerer = classEntries.some((e) => normTxt(e.className) === normTxt("Arcanista") && normTxt(e.path) === normTxt("Feiticeiro") && normTxt(e.lineage) === normTxt("Dracônica") ); return powers.filter((p) => { /* restrição explícita (overrides) — fonte de verdade */ if (p.restricted_to && !ruleMatches(p.restricted_to, ctx)) return false; if (p.power_type === "Poder Concedido") { if (!isDevoted || !savedDeityName) return false; const fromName = getPowerDeity(p.name); if (!fromName) return true; const deities = fromName.split(/,\s*/).map((s) => s.trim()); return deities.includes(savedDeityName); } if (p.power_type === "Poder de Raça") { if (!savedRace?.name) return false; const tag = getParenTag(p.name); if (!tag) return true; return raceTagMatches(tag, savedRace); } if (p.power_type === "Poder Dracônico") { /* Coração de Dragão: Kallyanach (raça) OU Feiticeiro Dracônico */ if (savedRace?.name && normTxt(savedRace.name) === normTxt("Kallyanach")) return true; if (hasDraconicSorcerer) return true; return false; } return true; }); } /* alias mantido para retrocompatibilidade com chamadas existentes */ const filterGrantedPowers = filterRestrictedPowers; /* ----- helper: corner brackets (mesmo do app principal) ----- */ function CreatorCorners() { return <> ; } /* ============================================================ DUPLO FEÉRICO BANNER — alerta cruzado origem ↔ classe Aceita array de class names (multiclasse). ============================================================ */ function DuploFeericoBanner({ classNames }) { const origin = (() => { try { return JSON.parse(localStorage.getItem("arton_char_origin")); } catch { return null; } })(); const duplo = origin?.duplo_pick; /* só aparece quando há um Duplo Feérico salvo */ if (!duplo?.class_name) return null; const names = (classNames || []).filter(Boolean); const isConflict = names.includes(duplo.class_name); return (
{isConflict ? "⚠" : "✦"}
{isConflict ? "Conflito com Duplo Feérico" : "Duplo Feérico ativo"}
Sua origem {origin.name} escolheu a habilidade{" "} {duplo.feature_name} de {duplo.class_name}. {isConflict ? ( <> {" "}Você não pode ter {duplo.class_name}{" "} entre suas classes, pois é a mesma do Duplo. {" "}Remova-a — ou volte à aba Origens e ajuste a habilidade duplicada. ) : ( <> {" "}Lembre-se: nenhuma de suas classes pode ser {duplo.class_name}. )}
); } /* ============================================================ helper: atributo de conjuração efetivo (path override) e sub-escolha encadeada do path_metadata ============================================================ */ function getEffectiveCastingAttribute(classData, entry) { const meta = classData?.path_metadata; if (!meta || !entry?.path) return classData?.casting_attribute; const opt = (meta.options || []).find((o) => o.id === entry.path); return opt?.casting_attribute || classData?.casting_attribute; } function getSubChoice(classData, entry) { const meta = classData?.path_metadata; if (!meta || !entry?.path) return null; const opt = (meta.options || []).find((o) => o.id === entry.path); return opt?.sub_choice || null; } /* ============================================================ CLASS CARD (uma classe na lista — multiclasse) ============================================================ */ function ClassCard({ entry, index, isActive, onActivate, onRemove, onChangeClass, onChangeLevel, onChangePath, onChangeLineage, onToggleMulti, availableClassNames, allClasses, }) { const classData = entry.className ? allClasses.find((c) => c.name === entry.className) : null; const options = entry.className && !availableClassNames.includes(entry.className) ? [entry.className, ...availableClassNames].sort((a, b) => a.localeCompare(b, "pt-BR")) : availableClassNames.slice().sort((a, b) => a.localeCompare(b, "pt-BR")); const pathMeta = classData?.path_metadata || null; const subChoice = getSubChoice(classData, entry); const effectiveAttr = getEffectiveCastingAttribute(classData, entry); return (
{index + 1}
{entry.className && (
e.stopPropagation()}>
{entry.level} nível
)} {/* === Sub-picker da escolha de classe (path/multi) === */} {pathMeta && (() => { const unlockLevel = pathMeta.unlock_level || 1; const locked = entry.level < unlockLevel; return (
e.stopPropagation()}>
{(pathMeta.options || []).map((opt) => { if (pathMeta.type === "multi") { const selected = (entry[pathMeta.save_key] || []).includes(opt.id); const full = !selected && (entry[pathMeta.save_key] || []).length >= (pathMeta.max || Infinity); const disabled = locked || full; return ( ); } return ( ); })}
); })()} {/* === Sub-picker de linhagem (encadeado) === */} {subChoice && (() => { const subUnlock = subChoice.unlock_level || 1; const subLocked = entry.level < subUnlock; return (
e.stopPropagation()}>
{(subChoice.options || []).map((opt) => ( ))}
); })()} {classData && (
d{classData.hp_die} {classData.skill_points} per. {classData.is_caster && ( {effectiveAttr} )}
)}
); } /* ============================================================ CLASS LIST PANEL (página esquerda — multiclasse) ============================================================ */ function ClassListPanel({ classes, allClasses, classEntries, activeIndex, setActiveIndex, onAdd, onRemove, onChangeClass, onChangeLevel, onChangePath, onChangeLineage, onToggleMulti, onOpenSummary, }) { /* classes ainda disponíveis (não usadas em nenhum card) */ const usedNames = new Set(classEntries.map((c) => c.className).filter(Boolean)); const availableClassNames = allClasses .map((c) => c.name) .filter((n) => !usedNames.has(n)); const totalLevel = classEntries.reduce( (sum, e) => sum + (e.className ? e.level : 0), 0 ); const activeEntry = classEntries[activeIndex] || null; const activeData = activeEntry?.className ? allClasses.find((c) => c.name === activeEntry.className) : null; /* nomes preenchidos para o banner */ const filledNames = classEntries.map((c) => c.className).filter(Boolean); /* impedir adicionar quando não há mais classes ou último card ainda vazio */ const lastEmpty = classEntries.length > 0 && !classEntries[classEntries.length - 1].className; const canAdd = availableClassNames.length > 0 && !lastEmpty; return (
Classes · Personagem {classEntries.length === 0 ? "Ficha" : `Nível ${totalLevel}`}

Criar Personagem

Adicione uma ou mais classes. Cada classe tem seu próprio nível.

{classEntries.map((entry, i) => ( setActiveIndex(i)} onRemove={() => onRemove(i)} onChangeClass={(name) => onChangeClass(i, name)} onChangeLevel={(lvl) => onChangeLevel(i, lvl)} onChangePath={(p) => onChangePath(i, p)} onChangeLineage={(l) => onChangeLineage(i, l)} onToggleMulti={(saveKey, id, max) => onToggleMulti(i, saveKey, id, max)} availableClassNames={availableClassNames} allClasses={allClasses} /> ))}
{activeData && ( <>
Classe ativa: {activeData.name}
{activeData.description && (

{activeData.description}

)}
Resistências boas
{(activeData.good_saves || []).join(" · ") || "—"}
{activeData.is_caster && ( )}
)}
); } /* ============================================================ PICKS PANEL (página direita) ============================================================ */ function PicksPanel({ classData, spellsData, loading, picks, setPick, onShowSpell, restrictedSchools }) { if (!classData) { return (
Picks de Magia aguardando
✦ ✦ ✦
Escolha uma classe à esquerda para começar.
); } if (!classData.is_caster) { return (
Picks de Magia

{classData.name}

Sem progressão de magias.

Esta classe não conjura magias.

O poder de {classData.name} vem de outras fontes — combate, destreza, instinto ou liderança. Veja a aba de Poderes.

); } const budget = spellsData?.budget_by_level || []; /* total de slots de magia (picks pode variar por nível — Bardo 2 no L1) */ const totalSlots = budget.reduce((sum, s) => sum + (s.picks || 0), 0); return (
Picks de Magia · {classData.name} {picks.filter(Boolean).length}/{totalSlots} escolhidas

Progressão

Cada slot só permite os círculos liberados no nível em que ele é aberto.

{loading && (
{[...Array(3)].map((_, i) =>
)}
)} {!loading && totalSlots === 0 && (
·
Ainda não há picks neste nível.
)} {!loading && (() => { let slotIdx = 0; const rows = []; for (const slot of budget) { const count = slot.picks || 0; for (let i = 0; i < count; i++) { const idx = slotIdx++; rows.push( 1 ? i + 1 : null} slotSubTotal={count > 1 ? count : null} spellsByCircle={spellsData.spells} currentPick={picks[idx]} allPicks={picks} onPick={(spell) => setPick(idx, spell)} onShowSpell={onShowSpell} restrictedSchools={restrictedSchools} /> ); } } return
{rows}
; })()}
); } /* ----- single row: nível + seletor de magia ----- */ function PickRow({ slot, slotIndex, slotSubIndex, slotSubTotal, spellsByCircle, currentPick, allPicks, onPick, onShowSpell, restrictedSchools }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const rootRef = useRef(); // fechar ao clicar fora useEffect(() => { if (!open) return; const onDoc = (e) => { if (!rootRef.current?.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [open]); // pool de magias permitidas neste slot (filtra por available_circles + escolas) const allowed = useMemo(() => { const list = []; for (const c of slot.available_circles) { const arr = spellsByCircle[String(c)] || []; list.push(...arr); } let filteredBySchool = list; if (Array.isArray(restrictedSchools) && restrictedSchools.length > 0) { const schoolSet = new Set(restrictedSchools.map((s) => normTxt(s))); filteredBySchool = list.filter((s) => schoolSet.has(normTxt(s.school))); } filteredBySchool.sort((a, b) => a.circle - b.circle || a.name.localeCompare(b.name, "pt-BR")); return filteredBySchool; }, [slot.available_circles, spellsByCircle, restrictedSchools]); // já escolhidas em outros slots (não desabilita o pick deste mesmo slot) const otherPickIds = new Set( allPicks .filter((p, idx) => p && idx !== slotIndex) .map((p) => p.id) ); const filtered = useMemo(() => { const q = query.toLowerCase().trim(); const matchQ = (s) => !q || s.name.toLowerCase().includes(q); const usable = allowed.filter((s) => matchQ(s) && !otherPickIds.has(s.id)); const used = allowed.filter((s) => matchQ(s) && otherPickIds.has(s.id)); return { usable, used }; }, [allowed, query, otherPickIds]); return (
{slot.level} {slotSubTotal ? `Nível · ${slotSubIndex}/${slotSubTotal}` : "Nível"}
{slot.available_circles.map((c) => ( {c}º círc. ))}
{currentPick ? ( ) : ( )} {open && (
setQuery(e.target.value)} />
{filtered.usable.map((s) => (
{ onPick(s); setOpen(false); setQuery(""); }} > {s.name} {s.school} {s.circle}º
))} {filtered.usable.length === 0 && filtered.used.length === 0 && (
Nenhuma magia.
)} {filtered.used.length > 0 && ( <>
já escolhidas em outros níveis
{filtered.used.map((s) => (
{s.name} {s.school} {s.circle}º
))} )}
)}
); } /* ============================================================ SUMMARY DRAWER ============================================================ */ function SummaryDrawer({ open, onClose, picks, classData, spellsData, onShowSpell }) { /* slot_index → nível em que o slot foi aberto (Bardo tem 2 no L1) */ const slotToLevel = useMemo(() => { const out = []; for (const b of spellsData?.budget_by_level || []) { for (let i = 0; i < (b.picks || 0); i++) out.push(b.level); } return out; }, [spellsData]); const grouped = useMemo(() => { const out = {}; for (let i = 0; i < picks.length; i++) { const s = picks[i]; if (!s) continue; const k = s.circle; (out[k] = out[k] || []).push({ spell: s, level: slotToLevel[i] ?? i + 1 }); } return out; }, [picks, slotToLevel]); const circles = Object.keys(grouped).map(Number).sort((a, b) => a - b); const total = picks.filter(Boolean).length; return (
Sumário · {classData?.name}
{total} {total === 1 ? "magia" : "magias"} escolhidas
{total === 0 && (
·
Nenhuma magia escolhida ainda.
)} {circles.map((c) => (

{c}º círculo {grouped[c].length}

{grouped[c].map(({ spell, level }) => (
onShowSpell(spell)}> Nv {level} {spell.name} {spell.school}
))}
))}
); } /* ============================================================ SPELL DETAIL MODAL Real API fields: casting_time, saving_throw, description ============================================================ */ function SpellDetail({ spell, onClose }) { if (!spell) return null; const paras = (spell.description || "").split("\n\n").filter(Boolean); return (
e.stopPropagation()}>
Magia {spell.type}

{spell.name}

{spell.school} · {spell.circle + "º círculo"}

{spell.casting_time &&
Execução
{spell.casting_time}
} {spell.range &&
Alcance
{spell.range}
} {spell.target &&
Alvo / Área
{spell.target}
} {spell.duration &&
Duração
{spell.duration}
} {spell.saving_throw && (
Resistência
{spell.saving_throw}
)}
{paras.map((p, i) => (

{p}

))}
); } /* ============================================================ POWER PICKS PANEL ============================================================ */ function PowerPicksPanel({ classData, powersData, powerPicks, setPowerPick, onShowPower }) { if (!classData) { return (
Poderes aguardando
✦ ✦ ✦
Escolha uma classe à esquerda para começar.
); } const budget = powersData?.budget_by_level || []; const slotLevels = budget.filter((b) => b.power_slots > 0); const totalPicked = powerPicks.filter(Boolean).length; return (
Poderes · {classData.name} {totalPicked}/{slotLevels.length} escolhidos

Progressão

Poderes ganhos por nível. Slots marcados com ◆ exigem escolha.

{!powersData && (
{[...Array(3)].map((_, i) =>
)}
)} {powersData && (
{budget.map((slot, slotIdx) => ( setPowerPick(slotIdx, power)} onShowPower={onShowPower} classData={classData} /> ))}
)}
); } /* ============================================================ POWER LEVEL ROW ============================================================ */ function PowerLevelRow({ slot, slotIndex, powersData, currentPick, allPicks, onPick, onShowPower, classData }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const rootRef = useRef(); const attrTotals = useAttrTotals(); useEffect(() => { if (!open) return; const onDoc = (e) => { if (!rootRef.current?.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [open]); const classPowers = powersData?.powers?.class || []; /* Poderes Concedidos só aparecem para classes devotas com divindade salva */ const generalPowers = filterGrantedPowers(powersData?.powers?.general || [], classData); const otherPickIds = new Set( allPicks.filter((p, i) => p && i !== slotIndex).map((p) => p.id) ); const filterPowers = (list) => { const q = query.toLowerCase().trim(); return list.filter((p) => !otherPickIds.has(p.id) && (!q || p.name.toLowerCase().includes(q))); }; const filteredClass = filterPowers(classPowers); const filteredGeneral = filterPowers(generalPowers); // Levels without a power slot: show fixed features only if (slot.power_slots === 0) { if (slot.fixed_features.length === 0) return null; return (
{slot.level} Nível
{slot.fixed_features.map((f) => ( {f} ))}
); } return (
{slot.level} Nível
{slot.fixed_features.length > 0 && (
{slot.fixed_features.map((f) => ( {f} ))}
)}
{currentPick ? (() => { const pickPrereq = meetsAttrPrereqs(currentPick, attrTotals); const chosenCls = "cc-spell-chosen" + (pickPrereq.ok ? "" : " is-conflict"); return ( ); })() : ( )} {open && (
setQuery(e.target.value)} />
{filteredClass.length > 0 && ( <>
{slot.power_category}
{filteredClass.map((p) => { const prereq = meetsAttrPrereqs(p, attrTotals); const cls = "cc-spell-opt" + (prereq.ok ? "" : " is-locked"); return (
{ if (!prereq.ok) return; onPick(p); setOpen(false); setQuery(""); }} title={prereq.ok ? "" : `Requer ${fmtPrereqLabel(prereq.missing)}`}> {p.name} {p.power_type} {!prereq.ok && ( Requer {fmtPrereqLabel(prereq.missing)} )}
); })} )} {filteredGeneral.length > 0 && ( <>
Poderes gerais
{filteredGeneral.map((p) => { const prereq = meetsAttrPrereqs(p, attrTotals); const cls = "cc-spell-opt" + (prereq.ok ? "" : " is-locked"); return (
{ if (!prereq.ok) return; onPick(p); setOpen(false); setQuery(""); }} title={prereq.ok ? "" : `Requer ${fmtPrereqLabel(prereq.missing)}`}> {p.name} {p.power_type} {!prereq.ok && ( Requer {fmtPrereqLabel(prereq.missing)} )}
); })} )} {filteredClass.length === 0 && filteredGeneral.length === 0 && (
Nenhum poder encontrado.
)}
)}
); } /* ============================================================ POWER DETAIL MODAL ============================================================ */ function PowerDetail({ power, onClose }) { if (!power) return null; const paras = (power.description || "").split("\n\n").filter(Boolean); return (
e.stopPropagation()}>
Poder {power.power_type}

{power.name}

{power.power_type} {power.power_category ? ` · ${power.power_category}` : ""}

{power.prerequisites && (
Pré-requisito
{power.prerequisites}
)}
{paras.map((p, i) =>

{p}

)}
); } /* ============================================================ ROOT ============================================================ */ /* ============================================================ LS helpers para multiclasse Formato novo: { classes: [{className, level, picks:ids[], powerPicks:ids[]}], activeIndex, activeTab } ============================================================ */ function loadCharClass() { try { const raw = JSON.parse(localStorage.getItem("arton_char_class")); if (raw && Array.isArray(raw.classes)) return raw; } catch {} return { classes: [], activeIndex: 0, activeTab: "magias" }; } function saveCharClass(data) { try { localStorage.setItem("arton_char_class", JSON.stringify(data)); } catch {} try { window.dispatchEvent(new CustomEvent("arton:char-changed")); } catch {} } function ClassCreator() { /* top-level multiclasse state */ const [data, setData] = useState(loadCharClass); /* derivar classe ativa */ const allClasses = window.CLASSES || []; const activeEntry = data.classes[data.activeIndex] || null; const selectedClass = activeEntry?.className || null; const characterLevel = activeEntry?.level || 1; const activeTab = data.activeTab; const setActiveTab = (tab) => setData((d) => ({ ...d, activeTab: tab })); /* picks/powers locais (espelham IDs no entry ativo) */ const [spellsData, setSpellsData] = useState(null); const [loading, setLoading] = useState(false); const [picks, setPicks] = useState([]); const [summaryOpen, setSummaryOpen] = useState(false); const [detailSpell, setDetailSpell] = useState(null); const [powersData, setPowersData] = useState(null); const [powerPicks, setPowerPicks] = useState([]); const [detailPower, setDetailPower] = useState(null); const restoredRef = useRef(false); const powerRestoredRef = useRef(false); /* Rastreia para qual classe os picks/powerPicks LOCAIS pertencem. Quando o usuário troca de classe ativa, esses ficam null durante a transição. O save aborta enquanto não combinarem com selectedClass. */ const picksOwnerRef = useRef(null); const powerPicksOwnerRef = useRef(null); const [spellPicksReady, setSpellPicksReady] = useState(false); const [powerPicksReady, setPowerPicksReady] = useState(false); const classData = useMemo( () => (selectedClass ? allClasses.find((c) => c.name === selectedClass) : null), [selectedClass, allClasses] ); const debouncedLevel = useDebounced(characterLevel, 300); /* persistir toda alteração no LS */ useEffect(() => { saveCharClass(data); }, [data]); /* === mutators do estado multiclasse === */ const addClass = () => setData((d) => { const next = { ...d, classes: [...d.classes, { className: null, level: 1, path: null, lineage: null, picks: [], powerPicks: [] }], activeIndex: d.classes.length, }; return next; }); const removeClass = (idx) => setData((d) => { const nextClasses = d.classes.filter((_, i) => i !== idx); let newActive = d.activeIndex; if (idx < d.activeIndex) newActive--; if (newActive >= nextClasses.length) newActive = Math.max(0, nextClasses.length - 1); return { ...d, classes: nextClasses, activeIndex: newActive }; }); const changeClassName = (idx, name) => setData((d) => { const nextClasses = d.classes.slice(); const cls = (window.CLASSES || []).find((c) => c.name === name); nextClasses[idx] = { ...nextClasses[idx], className: name, path: null, lineage: null, picks: [], powerPicks: [], }; return { ...d, classes: nextClasses, activeTab: cls?.is_caster ? "magias" : (cls ? "poderes" : d.activeTab), }; }); const changeClassLevel = (idx, lvl) => setData((d) => { const nextClasses = d.classes.slice(); nextClasses[idx] = { ...nextClasses[idx], level: lvl }; return { ...d, classes: nextClasses }; }); /* sub-arquétipo (path) — reseta lineage e picks de poderes que dependem dele */ const changeClassPath = (idx, path) => setData((d) => { const nextClasses = d.classes.slice(); const cur = nextClasses[idx] || {}; nextClasses[idx] = { ...cur, path: path || null, lineage: null, /* troca de path invalida lineage */ powerPicks: [], /* poderes podem ficar inválidos */ }; return { ...d, classes: nextClasses }; }); /* sub-escolha encadeada (lineage) */ const changeClassLineage = (idx, lineage) => setData((d) => { const nextClasses = d.classes.slice(); const cur = nextClasses[idx] || {}; nextClasses[idx] = { ...cur, lineage: lineage || null, powerPicks: [], /* idem */ }; return { ...d, classes: nextClasses }; }); /* Toggle de escolha multi-select (ex: Escolas de Magia do Bardo). Liga/desliga o id na lista, respeitando max. Limpa picks de magia, pois magias antigas podem não pertencer mais às escolas escolhidas. */ const toggleClassMulti = (idx, saveKey, id, max) => setData((d) => { const nextClasses = d.classes.slice(); const cur = nextClasses[idx] || {}; const list = Array.isArray(cur[saveKey]) ? cur[saveKey] : []; let nextList; if (list.includes(id)) { nextList = list.filter((x) => x !== id); } else if (!max || list.length < max) { nextList = [...list, id]; } else { return d; /* limite atingido */ } nextClasses[idx] = { ...cur, [saveKey]: nextList, picks: [], /* magias antigas podem ficar inválidas */ }; return { ...d, classes: nextClasses }; }); const setActiveIndex = (i) => setData((d) => ({ ...d, activeIndex: i })); /* ao mudar a classe ATIVA ou seu nome, reseta locais e força restauração */ useEffect(() => { const cls = (window.CLASSES || []).find((c) => c.name === selectedClass); setPicks([]); setPowerPicks([]); restoredRef.current = false; powerRestoredRef.current = false; picksOwnerRef.current = null; powerPicksOwnerRef.current = null; setSpellPicksReady(false); setPowerPicksReady(false); /* só forçar tab quando há classe — preserva escolha do usuário entre cards */ if (selectedClass) { setData((d) => ({ ...d, activeTab: cls?.is_caster ? d.activeTab : "poderes" })); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data.activeIndex, selectedClass]); /* ao trocar path/linhagem, limpa o estado LOCAL de powerPicks. (o LS já é zerado pelo changeClassPath/changeClassLineage; este efeito sincroniza a UI sem refetch — powersData continua válido). NÃO invalidamos `powerPicksReady` aqui (não há refetch para reativar). A guarda do save effect tolera o estado vazio idempotentemente. */ useEffect(() => { setPowerPicks([]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeEntry?.path, activeEntry?.lineage]); /* ao trocar escolhas multi-select que restringem magias (ex: escolas do Bardo), limpa o estado LOCAL de picks. O LS já foi zerado por toggleClassMulti. */ const restrictsSpellSchool = classData?.path_metadata?.restricts === "spell_school"; const multiKey = restrictsSpellSchool ? classData.path_metadata.save_key : null; const multiValue = multiKey ? activeEntry?.[multiKey] : null; useEffect(() => { if (multiKey) setPicks([]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [multiKey, multiValue]); // fetch dos spells sempre que classe ou (debounced) nível mudam. // IMPORTANTE: abortamos se debouncedLevel ainda não acompanhou characterLevel. // Sem isso, na troca de classe ativa, o fetch usa o nível STALE da classe // anterior, retornando slotCount errado e truncando picks da classe atual. useEffect(() => { let cancelled = false; if (!selectedClass) { setSpellsData(null); return; } if (debouncedLevel !== characterLevel) return; // debounce settling — aguardar const fetchOwner = selectedClass; setLoading(true); window.fetchClassSpells(selectedClass, debouncedLevel) .then((d) => { if (cancelled) return; setSpellsData(d); /* Achata o budget em uma lista de slots: cada slot herda available_circles do seu nível. Pode haver N slots por nível (ex.: Bardo tem 2 no L1). */ const flatSlots = []; for (const b of d.budget_by_level) { for (let i = 0; i < (b.picks || 0); i++) { flatSlots.push(b.available_circles); } } const totalSlots = flatSlots.length; // Primeira carga: tenta restaurar picks salvos no entry ativo if (!restoredRef.current) { restoredRef.current = true; const savedIds = activeEntry?.picks || []; if (savedIds.length) { const allSpells = Object.values(d.spells).flat(); const next = new Array(totalSlots).fill(null); for (let i = 0; i < Math.min(savedIds.length, totalSlots); i++) { if (!savedIds[i]) continue; const spell = allSpells.find(s => s.id === savedIds[i]); if (!spell) continue; const allowed = flatSlots[i]; if (allowed && allowed.includes(spell.circle)) next[i] = spell; } setPicks(next); picksOwnerRef.current = fetchOwner; setSpellPicksReady(true); return; } picksOwnerRef.current = fetchOwner; setSpellPicksReady(true); } /* Re-trim de picks ao mudar nível/classe: preserva apenas picks válidos para o slot daquela posição. */ setPicks((prev) => { const next = new Array(totalSlots).fill(null); for (let i = 0; i < Math.min(prev.length, totalSlots); i++) { const p = prev[i]; if (!p) continue; const allowed = flatSlots[i]; if (allowed && allowed.includes(p.circle)) next[i] = p; } return next; }); }) .catch((err) => { if (!cancelled) { console.warn("fetchClassSpells failed:", err); setSpellsData(null); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [selectedClass, debouncedLevel, characterLevel]); // fetch dos poderes sempre que classe ou (debounced) nível mudam. // IMPORTANTE: abortamos se debouncedLevel ainda não acompanhou characterLevel // (mesmo motivo do fetch de spells acima — evita fetch com nível STALE da // classe anterior, que truncaria os picks da classe atual). useEffect(() => { let cancelled = false; if (!selectedClass) { setPowersData(null); return; } if (debouncedLevel !== characterLevel) return; // debounce settling — aguardar // captura a identidade da classe para a qual estamos buscando — usada // tanto na restauração quanto no marcador de owner abaixo const fetchOwner = selectedClass; window.fetchClassPowers(selectedClass, debouncedLevel) .then((d) => { if (cancelled) return; setPowersData(d); const slotCount = (d.budget_by_level || []).length; // primeira carga: restaurar picks salvos if (!powerRestoredRef.current) { powerRestoredRef.current = true; const savedIds = activeEntry?.powerPicks || []; if (savedIds.length) { /* aplica filtro de poderes concedidos antes de restaurar */ const cls = (window.CLASSES || []).find((c) => c.name === selectedClass); const filteredGeneral = filterGrantedPowers(d.powers?.general || [], cls); const allPowers = [...(d.powers?.class || []), ...filteredGeneral]; const next = new Array(slotCount).fill(null); for (let i = 0; i < Math.min(savedIds.length, slotCount); i++) { if (!savedIds[i]) continue; const power = allPowers.find((p) => p.id === savedIds[i]); if (power) next[i] = power; } setPowerPicks(next); powerPicksOwnerRef.current = fetchOwner; setPowerPicksReady(true); return; } powerPicksOwnerRef.current = fetchOwner; setPowerPicksReady(true); } setPowerPicks((prev) => { if (prev.length <= slotCount) return prev; return prev.slice(0, slotCount); }); }) .catch((err) => { if (!cancelled) { console.warn("fetchClassPowers failed:", err); setPowersData(null); } }); return () => { cancelled = true; }; }, [selectedClass, debouncedLevel, characterLevel]); /* salvar picks no entry ativo sempre que mudam. NÃO incluímos `selectedClass` nas deps porque, ao trocar de classe ativa, este effect dispararia ANTES do reset zerar o estado local — e gravaria os picks da classe antiga no slot da classe nova. O guard `*PicksReady` já fica `false` durante a transição (zerado no reset effect) e só volta a `true` após a restauração da nova classe. */ useEffect(() => { if (!selectedClass || !spellPicksReady) return; // owner ref = a classe para a qual `picks` foi restaurado. // Se não bate com a classe atual, estamos em transição — aborta. if (picksOwnerRef.current !== selectedClass) return; const ids = picks.map((p) => p?.id || null); setData((d) => { const next = d.classes.slice(); const entry = next[d.activeIndex]; if (!entry || entry.className !== selectedClass) return d; next[d.activeIndex] = { ...entry, picks: ids }; return { ...d, classes: next }; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [picks, spellPicksReady]); useEffect(() => { if (!selectedClass || !powerPicksReady) return; if (powerPicksOwnerRef.current !== selectedClass) return; const ids = powerPicks.map((p) => p?.id || null); setData((d) => { const next = d.classes.slice(); const entry = next[d.activeIndex]; if (!entry || entry.className !== selectedClass) return d; next[d.activeIndex] = { ...entry, powerPicks: ids }; return { ...d, classes: next }; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [powerPicks, powerPicksReady]); const setPick = (slotIdx, spell) => { setPicks((prev) => { const next = prev.slice(); while (next.length <= slotIdx) next.push(null); next[slotIdx] = spell; return next; }); }; const setPowerPick = (slotIndex, power) => { setPowerPicks((prev) => { const next = prev.slice(); while (next.length <= slotIndex) next.push(null); next[slotIndex] = power; return next; }); }; /* empty state — nenhuma classe ainda */ if (data.classes.length === 0) { return (
setSummaryOpen(true)} />
Picks
✦ ✦ ✦
Adicione uma classe à esquerda para começar.
); } return (
setSummaryOpen(true)} />
{classData?.is_caster && ( )}
{activeTab === "magias" ? ( <> setSummaryOpen(false)} picks={picks} classData={classData} spellsData={spellsData} onShowSpell={setDetailSpell} /> ) : ( )}
setDetailSpell(null)}/> setDetailPower(null)}/>
); } window.ClassCreator = ClassCreator;