/* global React, window, Kw, Parsed */ /* ============================================================ RACE + ORIGIN CREATOR — abas "Raças" e "Origens" Lista filtrável à esquerda · detalhes à direita. Seleção persiste em localStorage. Preparado para grandes volumes (50+ raças, 100+ origens): – busca em tempo real – filtro por fonte (chips) – lista ordenada alfabeticamente – scroll nos dois lados ============================================================ */ const { useState, useEffect, useMemo } = React; const LS_RACE = "arton_char_race"; const LS_ORIGIN = "arton_char_origin"; const LS_CLASS = "arton_char_class"; /* ============================================================ REGRAS ESPECIAIS POR ORIGEM ============================================================ */ /** "Duplo Feérico (Pondsmânia)" exige escolher uma habilidade de classe de 1º nível de uma classe diferente da sua. */ function isDuploFeerico(origin) { return !!origin && /^Duplo Feérico/i.test(origin.name); } /** Lê as classes salvas do personagem (multiclasse). */ function getSavedClassNames() { try { const data = JSON.parse(localStorage.getItem(LS_CLASS)); if (data && Array.isArray(data.classes)) { return data.classes.map((c) => c.className).filter(Boolean); } } catch {} return []; } /** Normaliza nome removendo parênteses finais — "Magias (1º círculo)" → "magias". */ function normalizeFeatureName(s) { return (s || "").replace(/\s*\([^)]*\)\s*$/, "").trim().toLowerCase(); } /** Para uma classe, devolve as habilidades de 1º nível (sem slots de poder), resolvendo nome → descrição completa via features[]. */ function getLevel1Abilities(classObj) { const prog = classObj?.feature_progression?.["1"]; if (!Array.isArray(prog)) return []; const features = classObj.features || []; return prog .filter(p => !p.is_power_slot) .map(p => { const norm = normalizeFeatureName(p.name); const full = features.find(f => f.level === 1 && normalizeFeatureName(f.name) === norm ); return { name: full?.name || p.name, description: full?.description || "", }; }); } /** Marca "Magias" e variantes para alertas especiais do Duplo Feérico. */ function isMagiasAbility(featureName) { return /^magias\b/i.test(featureName || ""); } /* Ofícios disponíveis para o sub-picker */ const OFICIO_TYPES = [ "alfaiate","alquimista","armeiro","artesão","barbeiro","carpinteiro", "cozinheiro","coureiro","escriba","escultor","ferreiro","joalheiro", "marceneiro","minerador","músico","ourives","pedreiro","pescador", "pintor","sapateiro","servial","tecelão", ]; /* Perícias para o sub-picker de "qualquer perícia" */ const T20_SKILL_LIST = [ "Acrobacia","Adestramento","Arte","Atletismo","Atuação", "Cavalgar","Conhecimento","Cura","Diplomacia","Enganação", "Fortitude","Furtividade","Guerra","Iniciativa","Intimidação", "Intuição","Investigação","Jogatina","Ladinagem","Luta", "Misticismo","Nobreza","Ofício","Percepção","Pilotagem", "Pontaria","Reflexos","Religião","Sobrevivência","Vontade", ]; function lsGet(key) { try { return JSON.parse(localStorage.getItem(key)); } catch { return null; } } function lsSet(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch {} try { window.dispatchEvent(new CustomEvent("arton:char-changed")); } catch {} } function RCCorners() { return <> ; } /* chips de fonte — só aparece quando há múltiplas fontes */ function SourceChips({ sources, active, onToggle }) { if (!sources || sources.length <= 1) return null; return (
{sources.map(s => ( ))}
); } const ATTR_NAMES = { for: "Força", des: "Destreza", con: "Constituição", int: "Inteligência", sab: "Sabedoria", car: "Carisma" }; /* "Força +2 · Constituição +1" — handles real API format */ function fmtAttrMods(mods) { if (!mods) return "—"; const parts = []; for (const [k, v] of Object.entries(mods)) { if (k === "variable") { if (v && v.count > 0) parts.push(`Escolha ${v.count} atributo${v.count !== 1 ? "s" : ""} +${v.amount}`); } else if (v) { parts.push(`${ATTR_NAMES[k] || k} ${v > 0 ? "+" : ""}${v}`); } } return parts.length ? parts.join(" · ") : "—"; } /* ============================================================ RACE LIST (página esquerda) ============================================================ */ function RaceList({ races, total, selected, saved, onSelect, query, setQuery, sources, activeSource, onSourceToggle }) { const folioLabel = races.length < total ? `${races.length} de ${total}` : String(total); return (
Raças · Personagem {folioLabel} raças

Escolher Raça

A raça concede modificadores de atributo, habilidades raciais e slots de poder.

setQuery(e.target.value)} />
{races.map(r => { const isSaved = saved?.id === r.id; const isSelected = selected?.id === r.id; return ( ); })} {races.length === 0 && (
Nenhuma raça encontrada.
)}
); } /* ============================================================ RACE DETAIL (página direita) ============================================================ */ const ATTR_ABBREVS = ["FOR", "DES", "CON", "INT", "SAB", "CAR"]; const ATTR_LABELS = { FOR: "Força", DES: "Destreza", CON: "Constituição", INT: "Inteligência", SAB: "Sabedoria", CAR: "Carisma" }; function VariableAttrPicker({ rule, picks, onChange }) { if (!rule || !rule.count) return null; const chosen = picks.filter(Boolean); const sign = rule.amount >= 0 ? "+" : ""; function toggle(attr) { const isActive = chosen.includes(attr); let next; if (isActive) { next = chosen.filter((a) => a !== attr); } else { if (chosen.length >= rule.count) return; /* já cheio */ next = [...chosen, attr]; } /* mantém o tamanho do array igual a rule.count, preenchendo com null */ while (next.length < rule.count) next.push(null); onChange(next); } return (
Escolha {rule.count} atributo{rule.count !== 1 ? "s" : ""} {sign}{rule.amount} ({chosen.length}/{rule.count})
{ATTR_ABBREVS.map((a) => { const isActive = chosen.includes(a); const isFull = chosen.length >= rule.count && !isActive; return ( ); })}
); } function RaceDetail({ race, saved, onSave, variablePicks, onPicksChange }) { if (!race) { return (
Detalhes
✦ ✦ ✦
Selecione uma raça à esquerda.
); } const isSaved = saved?.id === race.id; const variableRule = race?.attr_modifiers?.variable; const hasVariable = !!(variableRule && variableRule.count > 0); const picksComplete = !hasVariable || (Array.isArray(variablePicks) && variablePicks.length === variableRule.count && variablePicks.every(Boolean)); return (
Raça · {race.source} {race.racial_power_slots} slot{race.racial_power_slots !== 1 ? "s" : ""} racial{race.racial_power_slots !== 1 ? "is" : ""}

{race.name}

{race.variant &&

{race.variant}

}
{Object.entries(race.attr_modifiers || {}).map(([k, v]) => { if (k === "variable") return null; /* renderizado pelo picker abaixo */ if (!v) return null; return (
{ATTR_NAMES[k] || k}
{v > 0 ? "+" : ""}{v}
); })}
Poderes raciais
{race.racial_power_slots} slot{race.racial_power_slots !== 1 ? "s" : ""}
{hasVariable && ( )}
{(race.abilities || []).map((ab, i) => (
{ab.name}
{ab.description}
))}
); } /* ============================================================ RACE CREATOR (root da aba "racas") ============================================================ */ function RaceCreator() { const [allRaces, setAllRaces] = useState([]); const [loading, setLoading] = useState(true); const [query, setQuery] = useState(""); const [activeSource, setActiveSource] = useState(null); const [selected, setSelected] = useState(null); const [saved, setSaved] = useState(() => lsGet(LS_RACE)); /* picks da regra "Escolha X atributos +Y" — array de abreviações (FOR/DES/...) */ const [variablePicks, setVariablePicks] = useState(() => { const s = lsGet(LS_RACE); return Array.isArray(s?.variable_picks) ? s.variable_picks : []; }); useEffect(() => { window.fetchRaces().then(data => { const sorted = [...data].sort((a, b) => a.name.localeCompare(b.name, "pt-BR") || (a.variant || "").localeCompare(b.variant || "", "pt-BR") ); setAllRaces(sorted); setLoading(false); const s = lsGet(LS_RACE); if (s) setSelected(sorted.find(r => r.id === s.id) || null); }); }, []); /* Ao trocar a raça SELECIONADA (navegação na lista), inicializa os picks: se for a raça já salva, recarrega; senão limpa (a quantidade depende da raça). */ useEffect(() => { if (!selected) { setVariablePicks([]); return; } if (saved?.id === selected.id && Array.isArray(saved.variable_picks)) { setVariablePicks(saved.variable_picks); } else { const n = selected?.attr_modifiers?.variable?.count || 0; setVariablePicks(Array(n).fill(null)); } }, [selected?.id]); const sources = useMemo(() => [...new Set(allRaces.map(r => r.source))].sort(), [allRaces]); const filtered = useMemo(() => { let out = allRaces; if (activeSource) out = out.filter(r => r.source === activeSource); const q = query.toLowerCase().trim(); if (q) out = out.filter(r => r.name.toLowerCase().includes(q) || (r.variant || "").toLowerCase().includes(q) ); return out; }, [allRaces, query, activeSource]); const handleSave = (race, picks) => { const val = { id: race.id, name: race.name, variant: race.variant, source: race.source }; if (Array.isArray(picks) && picks.length > 0) val.variable_picks = picks; lsSet(LS_RACE, val); setSaved(val); /* defesa em duas camadas: lsSet já dispara, mas micro-task garante que qualquer leitura síncrona de localStorage ocorra depois do write. */ queueMicrotask(() => { try { window.dispatchEvent(new CustomEvent("arton:char-changed")); } catch {} }); }; /* Re-dispatch char-changed a cada mudança de saved (defesa contra batching). */ useEffect(() => { try { window.dispatchEvent(new CustomEvent("arton:char-changed")); } catch {} }, [saved]); /* Quando a raça selecionada JÁ É a raça salva e o usuário altera picks, persiste imediatamente — o painel reage via storage event. */ const handlePicksChange = (nextPicks) => { setVariablePicks(nextPicks); if (selected && saved?.id === selected.id) { const val = { id: saved.id, name: saved.name, variant: saved.variant, source: saved.source }; if (nextPicks.length > 0) val.variable_picks = nextPicks; lsSet(LS_RACE, val); setSaved(val); } }; return (
{loading ?
·
Carregando raças…
: setActiveSource(prev => prev === s ? null : s)} /> }
); } /* ============================================================ ORIGIN LIST (página esquerda) ============================================================ */ function OriginList({ origins, total, selected, saved, onSelect, query, setQuery, sources, activeSource, onSourceToggle }) { const folioLabel = origins.length < total ? `${origins.length} de ${total}` : String(total); return (
Origens · Personagem {folioLabel} origens

Escolher Origem

A origem define o passado do personagem e concede perícias, equipamentos e bônus de atributo.

setQuery(e.target.value)} />
{origins.map(o => { const isSaved = saved?.id === o.id; const isSelected = selected?.id === o.id; const attrLine = (o.benefits || []) .filter(b => b.type === "attribute") .map(b => `${b.name} ${b.bonus}`) .join(" · "); const skillLine = (o.benefits || []) .filter(b => b.type === "skill") .map(b => b.name) .join(" · "); return ( ); })} {origins.length === 0 && (
Nenhuma origem encontrada.
)}
); } /* ============================================================ ORIGIN DETAIL (página direita) ============================================================ */ /* ============================================================ DUPLO FEÉRICO PICKER — escolha de habilidade de classe extra ============================================================ */ function DuploFeericoPicker({ duploPick, setDuploPick, savedClassNames }) { const allClasses = window.CLASSES || []; const charClassesSet = new Set(savedClassNames || []); /* Sem dados? avisa em vez de quebrar silenciosamente. */ if (allClasses.length === 0) { return (
Habilidade Duplicada

Lista de classes ainda não carregada. Tente recarregar a página.

); } /* Classes elegíveis: todas exceto as do personagem. */ const eligible = allClasses .filter(c => !charClassesSet.has(c.name)) .slice() .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")); /* Conflito: a classe escolhida no Duplo agora é uma das classes do personagem. */ const hasConflict = !!duploPick?.class_name && charClassesSet.has(duploPick.class_name); const selectedClass = eligible.find(c => c.name === duploPick?.class_name) || null; const abilities = selectedClass ? getLevel1Abilities(selectedClass) : []; const pickedAbility = duploPick?.feature_name ? abilities.find(a => a.name === duploPick.feature_name) || null : null; const handleClassPick = (className) => { /* Trocar de classe limpa a habilidade. */ setDuploPick({ class_name: className, feature_name: null }); }; const handleAbilityPick = (abilityName) => { setDuploPick(prev => ({ class_name: prev?.class_name || selectedClass?.name || null, feature_name: prev?.feature_name === abilityName ? null : abilityName, })); }; return (
Habilidade Duplicada {duploPick?.feature_name && ( {duploPick.class_name} · {duploPick.feature_name} )}

{savedClassNames && savedClassNames.length > 0 ? <>Suas classes atuais: {savedClassNames.map((n, i) => ( {i > 0 && " · "}{n} ))}. Escolha uma habilidade de 1º nível de uma classe diferente. : <>Você ainda não escolheu uma classe. Quando escolher, voltará aqui para excluí-la da lista. }

{hasConflict && (
⚠ Conflito: {duploPick.class_name} {" "}agora é uma das suas classes. Selecione outra classe abaixo.
)} {/* passo 1 — classe */}
1. Classe
{eligible.map(c => ( ))}
{/* passo 2 — habilidade */} {selectedClass && ( <>
2. Habilidade de {selectedClass.name}
{abilities.length === 0 ? (

Nenhuma habilidade de 1º nível registrada para esta classe.

) : (
{abilities.map(a => { const isSelected = duploPick?.feature_name === a.name; const isMagias = isMagiasAbility(a.name); return (
handleAbilityPick(a.name)} >
{a.name} {isMagias && ( ⚠ regra especial )}
); })}
)} )} {/* descrição da habilidade escolhida */} {pickedAbility?.description && (
{pickedAbility.description} {isMagiasAbility(pickedAbility.name) && (

Atenção: ao escolher Magias via Duplo Feérico, você aprende uma única magia e recebe +1 PM, mas não soma o atributo-chave da habilidade no seu total de PM.

)}
)}
); } function OriginDetail({ origin, saved, onSave, benefitPicks, setBenefitPicks, duploPick, setDuploPick, savedClassNames }) { if (!origin) { return (
Detalhes
✦ ✦ ✦
Selecione uma origem à esquerda.
); } const picks = benefitPicks || []; const isSaved = saved?.id === origin.id; const attrBonuses = (origin.benefits || []).filter(b => b.type === "attribute"); const choosable = (origin.benefits || []).filter(b => b.type !== "attribute"); /* pending = primeiro skill_choice selecionado que ainda não foi resolvido */ const pendingChoice = picks.find(p => p.type === "skill_choice"); /* resolved picks for statblock */ const pickedSkills = picks.filter(p => p.type === "skill"); const pickedPowers = picks.filter(p => p.type === "power"); /* unresolved skill_choice — shown in statblock as "(a escolher)" */ const unresolvedChoices = picks.filter(p => p.type === "skill_choice"); /* toggle a choosable benefit in/out of picks */ const togglePick = (b) => { setBenefitPicks(prev => { const idx = prev.findIndex(p => p.type === b.type && p.name === b.name && p.category === b.category ); if (idx >= 0) return prev.filter((_, i) => i !== idx); if (prev.length >= 2) return prev; return [...prev, b]; }); }; /* resolve a skill_choice pick to a concrete skill name */ const resolveChoice = (choice, resolvedName) => { setBenefitPicks(prev => { const idx = prev.findIndex(p => p.type === "skill_choice" && p.name === choice.name && p.category === choice.category ); if (idx < 0) return prev; const next = [...prev]; next[idx] = { type: "skill", name: resolvedName, wasChoice: true }; return next; }); }; /* sub-options for the pending sub-picker */ const subOptions = pendingChoice ? pendingChoice.category === "Ofício" ? OFICIO_TYPES.map(t => `Ofício (${t})`) : T20_SKILL_LIST : []; return (
Origem · {origin.source}

{origin.name}

{attrBonuses.map((b, i) => (
{b.name || "Atributo"}
{b.bonus}
))} {(pickedSkills.length > 0 || unresolvedChoices.length > 0) && (
Perícias escolhidas
{[ ...pickedSkills.map(s => s.name), ...unresolvedChoices.map(c => c.category === "Ofício" ? "Ofício (a escolher)" : "Perícia (a escolher)" ), ].join(" · ")}
)} {pickedPowers.length > 0 && (
Poderes escolhidos
{pickedPowers.map(p => p.name).join(" · ")}
)}
{choosable.length > 0 && (
Escolha 2 benefícios ({picks.length}/2)
{choosable.map((b, i) => { const isActive = picks.some(p => p.type === b.type && p.name === b.name && p.category === b.category ); const isFull = picks.length >= 2 && !isActive; const label = b.type === "skill_choice" ? (b.category === "Ofício" ? "Ofício ▾" : "Perícia ▾") : b.name; return ( ); })}
{/* Sub-picker — aparece quando um skill_choice está selecionado */} {pendingChoice && (
{pendingChoice.category === "Ofício" ? "Qual ofício?" : "Qual perícia?"}
{subOptions.map((opt, i) => ( ))}
)}
)} {origin.items && (
Equipamentos iniciais

{origin.items}

)} {/* Regra especial: Duplo Feérico exige uma habilidade extra */} {isDuploFeerico(origin) && ( )}
{(origin.description || "").split("\n\n").map((p, i) => (

{p}

))}
{(() => { const needsPicks = choosable.length > 0 && picks.length < 2; const needsDuplo = isDuploFeerico(origin) && !duploPick?.feature_name; const messages = []; if (needsPicks) { const faltam = 2 - picks.length; messages.push(`Escolha ${faltam} benefício${faltam !== 1 ? "s" : ""}`); } if (needsDuplo) { messages.push("Escolha a habilidade duplicada"); } const canSave = !needsPicks && !needsDuplo; return ( <> {messages.length > 0 && (
{messages.join(" · ")} antes de salvar
)} ); })()}
); } /* ============================================================ ORIGIN CREATOR (root da aba "origens") ============================================================ */ function OriginCreator() { const [allOrigins, setAllOrigins] = useState([]); const [loading, setLoading] = useState(true); const [query, setQuery] = useState(""); const [activeSource, setActiveSource] = useState(null); const [selected, setSelected] = useState(null); const [saved, setSaved] = useState(() => lsGet(LS_ORIGIN)); const [benefitPicks, setBenefitPicks] = useState(() => lsGet(LS_ORIGIN)?.picks || []); /* escolha extra do Duplo Feérico: { class_name, feature_name } | null */ const [duploPick, setDuploPick] = useState(() => lsGet(LS_ORIGIN)?.duplo_pick || null); const [savedClassNames, setSavedClassNames] = useState(() => getSavedClassNames()); /* sincroniza classes do personagem se o jogador as trocou em outra aba */ useEffect(() => { const sync = () => setSavedClassNames(getSavedClassNames()); window.addEventListener("focus", sync); return () => window.removeEventListener("focus", sync); }, []); useEffect(() => { window.fetchOrigins().then(data => { const sorted = [...data].sort((a, b) => a.name.localeCompare(b.name, "pt-BR")); setAllOrigins(sorted); setLoading(false); const s = lsGet(LS_ORIGIN); if (s) setSelected(sorted.find(o => o.id === s.id) || null); }); }, []); // When selected origin changes, restore or reset picks + duploPick useEffect(() => { if (!selected) { setBenefitPicks([]); setDuploPick(null); return; } const s = lsGet(LS_ORIGIN); if (s && s.id === selected.id) { setBenefitPicks(s.picks || []); setDuploPick(s.duplo_pick || null); } else { setBenefitPicks([]); setDuploPick(null); } }, [selected?.id]); const sources = useMemo(() => [...new Set(allOrigins.map(o => o.source))].sort(), [allOrigins]); const filtered = useMemo(() => { let out = allOrigins; if (activeSource) out = out.filter(o => o.source === activeSource); const q = query.toLowerCase().trim(); if (q) out = out.filter(o => o.name.toLowerCase().includes(q)); return out; }, [allOrigins, query, activeSource]); const handleSave = origin => { const val = { id: origin.id, name: origin.name, source: origin.source, picks: benefitPicks, /* só inclui duplo_pick em origens que exigem (mantém o LS limpo) */ duplo_pick: isDuploFeerico(origin) ? duploPick : null, }; lsSet(LS_ORIGIN, val); setSaved(val); }; return (
{loading ?
·
Carregando origens…
: setActiveSource(prev => prev === s ? null : s)} /> }
); } window.RaceCreator = RaceCreator; window.OriginCreator = OriginCreator;