/* 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 => (
onToggle(s)}
>
{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 (
onSelect(r)}
>
{r.name}{r.variant ? · {r.variant} : null}
{r.source}
{fmtAttrMods(r.attr_modifiers)}
{isSaved && ✦ Salvo }
);
})}
{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 (
toggle(a)}
title={ATTR_LABELS[a]}
>
{ATTR_LABELS[a]} {sign}{rule.amount}
);
})}
);
}
function RaceDetail({ race, saved, onSave, variablePicks, onPicksChange }) {
if (!race) {
return (
✦ ✦ ✦
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) => (
))}
{
if (isSaved || !picksComplete) return;
onSave(race, hasVariable ? variablePicks : undefined);
}}
disabled={!isSaved && !picksComplete}
title={!picksComplete ? `Faltam ${variableRule.count - variablePicks.filter(Boolean).length} escolha(s) de atributo` : ""}
>
{isSaved
? "✦ Raça selecionada"
: (picksComplete ? "Selecionar esta raça" : `Escolha ${variableRule.count - variablePicks.filter(Boolean).length} atributo(s)`)}
);
}
/* ============================================================
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
?
:
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 (
onSelect(o)}
>
{o.name}
{o.source}
{attrLine || skillLine}
{isSaved && ✦ Salvo }
);
})}
{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 => (
handleClassPick(c.name)}
>
{c.name}
))}
{/* 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 (
✦ ✦ ✦
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 (
togglePick(b)}
>
{label}
);
})}
{/* Sub-picker — aparece quando um skill_choice está selecionado */}
{pendingChoice && (
{pendingChoice.category === "Ofício"
? "Qual ofício?"
: "Qual perícia?"}
{subOptions.map((opt, i) => (
resolveChoice(pendingChoice, opt)}
>
{opt}
))}
)}
)}
{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
)}
canSave && !isSaved && onSave(origin)}
>
{isSaved ? "✦ Origem selecionada" : "Selecionar esta origem"}
>
);
})()}
);
}
/* ============================================================
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
?
:
setActiveSource(prev => prev === s ? null : s)}
/>
}
);
}
window.RaceCreator = RaceCreator;
window.OriginCreator = OriginCreator;