/* global React, window */ /* ============================================================ SKILLS CREATOR — Perícias do Escriba de Arton ============================================================ - Lê window.CHAR_SKILLS (store) + window.CHAR_ATTRS + window.SKILLS - Layout no spread do livro: página esquerda = síntese, página direita = grid de perícias agrupado por atributo. - Cada perícia treinada mostra a fonte (class_fixed | class_choice | int_bonus | origin | power) via borda lateral colorida + selo abreviado. - Briefing original em: pasted_text (m0001). UI provisória. ============================================================ */ (function () { "use strict"; const { useState, useEffect, useMemo, useCallback, useRef } = React; // ----------------------------------------------------------------------- // Constantes // ----------------------------------------------------------------------- const ATTRS = ["FOR", "DES", "CON", "INT", "SAB", "CAR"]; const ATTR_LABELS = { FOR: "Força", DES: "Destreza", CON: "Constituição", INT: "Inteligência", SAB: "Sabedoria", CAR: "Carisma", }; // Fontes da treinamento — chave do skills-store const SOURCE_META = { class_fixed: { short: "FIX", label: "Fixa da classe", tone: "class-fixed" }, class_choice: { short: "CL", label: "Escolha de classe", tone: "class-choice" }, int_bonus: { short: "INT", label: "Bônus de Inteligência", tone: "int" }, race: { short: "RAC", label: "Raça", tone: "race" }, origin: { short: "ORI", label: "Origem", tone: "origin" }, power: { short: "POD", label: "Poder", tone: "power" }, }; const SOURCE_ORDER = ["class_fixed", "class_choice", "int_bonus", "race", "origin", "power"]; // ----------------------------------------------------------------------- // Helpers de regra T20 // ----------------------------------------------------------------------- function trainingBonus(level) { if (level >= 15) return 6; if (level >= 7) return 4; return 2; } function calcBonus({ trained, level, attrMod }) { const half = Math.floor((level || 1) / 2); const tr = trained ? trainingBonus(level || 1) : 0; return { total: half + (attrMod || 0) + tr, half, train: tr, attr: attrMod || 0 }; } function lsRead(key) { try { return JSON.parse(localStorage.getItem(key)); } catch { return null; } } /* O briefing diz "arton_class.level", mas o class-creator real salva em "arton_char_class" como { classes: [{className, level, …}, …] }. Tentamos as duas formas pra não quebrar. */ function readCharLevel() { const a = lsRead("arton_char_class"); if (a && Array.isArray(a.classes) && a.classes.length) { return a.classes.reduce((sum, c) => sum + (c.level || 1), 0); } const b = lsRead("arton_class"); if (b && typeof b.level === "number") return b.level; return 1; } // ----------------------------------------------------------------------- // Hooks // ----------------------------------------------------------------------- function useSkillsState() { const [s, setS] = useState(() => window.CHAR_SKILLS ? window.CHAR_SKILLS.getState() : null ); useEffect(() => { if (!window.CHAR_SKILLS) return; setS(window.CHAR_SKILLS.getState()); return window.CHAR_SKILLS.subscribe(setS); }, []); return s; } function useCharAttrs() { const [a, setA] = useState(() => window.CHAR_ATTRS ? window.CHAR_ATTRS.getState() : { totals: {} } ); useEffect(() => { if (!window.CHAR_ATTRS) return; setA(window.CHAR_ATTRS.getState()); return window.CHAR_ATTRS.subscribe(setA); }, []); return a; } function useCharLevel() { const [lvl, setLvl] = useState(() => readCharLevel()); useEffect(() => { const onChange = () => setLvl(readCharLevel()); window.addEventListener("arton:char-changed", onChange); document.addEventListener("arton:char-changed", onChange); window.addEventListener("storage", onChange); return () => { window.removeEventListener("arton:char-changed", onChange); document.removeEventListener("arton:char-changed", onChange); window.removeEventListener("storage", onChange); }; }, []); return lvl; } // ----------------------------------------------------------------------- // Componentes pequenos // ----------------------------------------------------------------------- function SkCorners() { return ( <> ); } function ShieldIcon({ size = 13 }) { /* ícone de elmo/escudo — sinaliza penalidade de armadura */ return ( ); } function LockIcon({ size = 13 }) { return ( ); } function formatSigned(n) { return n >= 0 ? `+${n}` : `${n}`; } // ----------------------------------------------------------------------- // Conflitos banner // ----------------------------------------------------------------------- function ConflictsBanner({ conflicts }) { if (!conflicts || conflicts.length === 0) return null; const onResolve = () => { if (!window.CHAR_SKILLS) return; const state = window.CHAR_SKILLS.getState(); const valid = new Set([...(state.options || [])]); const saved = lsRead("arton_skills") || {}; // Só filtra o slot de classe — int_bonus aceita qualquer perícia (regra T20). const newChoices = (saved.choices || []).filter((s) => valid.has(s)); const newInt = saved.int_bonus_choices || []; window.CHAR_SKILLS.setChoices(newChoices, newInt); }; return (
Conflitos de perícia {conflicts.length}

As perícias abaixo não pertencem à classe atual:  {conflicts.map((c, i) => ( {c}{i < conflicts.length - 1 ? "" : ""} ))}

); } // ----------------------------------------------------------------------- // SkillCard // ----------------------------------------------------------------------- function SkillCard({ skill, state, level, attrMod }) { const trainedEntry = state.trained[skill.name]; const trained = !!trainedEntry; const source = trainedEntry?.source || null; const inClassOptions = (state.options || []).includes(skill.name); const classSlotsLeft = state.slots_total - state.slots_used; const intSlotsLeft = state.slots_int - state.slots_int_used; /* Regra T20: bônus de INT pode treinar QUALQUER perícia, mesmo fora da lista. - canPickClass: tem slot de classe + perícia está na lista da classe - canPickInt: tem slot de INT (qualquer perícia) O store roteia automaticamente: classe primeiro (se aplicável), depois INT. */ const canPickClass = !trained && inClassOptions && classSlotsLeft > 0; const canPickInt = !trained && intSlotsLeft > 0; const canPick = canPickClass || canPickInt; /* trained_only só restringe USO em jogo, não a escolha durante a criação. Mantemos um flag informativo para o tooltip. */ const needsTraining = skill.trained_only && !trained; const bonus = calcBonus({ trained, level, attrMod }); const meta = source ? SOURCE_META[source] : null; const tone = meta?.tone || (canPickClass ? "pickable" : canPickInt ? "pickable-int" : "untrained"); const [openDesc, setOpenDesc] = useState(false); function toggleChoice() { if (!window.CHAR_SKILLS) return; if (trained && (source === "class_choice" || source === "int_bonus")) { window.CHAR_SKILLS.removeChoice(skill.name); } else if (canPick) { window.CHAR_SKILLS.addChoice(skill.name); } } const interactive = canPick || (trained && (source === "class_choice" || source === "int_bonus")); return (
{ if (interactive && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); toggleChoice(); } }} >
); } // ----------------------------------------------------------------------- // Grupo por atributo // ----------------------------------------------------------------------- function AttrGroup({ attr, skills, state, level, attrMod, filter }) { if (!skills.length) return null; const filtered = filter ? skills.filter(s => s.name.toLowerCase().includes(filter.toLowerCase())) : skills; if (!filtered.length) return null; /* ordena: treinadas primeiro (na ordem das fontes), depois disponíveis pela classe (90), depois disponíveis via INT (95), depois resto (99) */ const intLeft = state.slots_int - state.slots_int_used; const rankFor = (skill) => { const t = state.trained[skill.name]; if (t) return SOURCE_ORDER.indexOf(t.source); if ((state.options || []).includes(skill.name)) return 90; if (intLeft > 0) return 95; return 99; }; const sorted = [...filtered].sort((a, b) => { const ra = rankFor(a); const rb = rankFor(b); if (ra !== rb) return ra - rb; return a.name.localeCompare(b.name, "pt-BR"); }); return (
{attr} {ATTR_LABELS[attr]} {formatSigned(attrMod || 0)}
{sorted.map(s => ( ))}
); } // ----------------------------------------------------------------------- // Síntese (página esquerda) // ----------------------------------------------------------------------- function SynthesisPage({ state, level, filter, setFilter }) { const trainedList = useMemo(() => { const arr = Object.entries(state.trained).map(([name, info]) => ({ name, source: info.source, })); arr.sort((a, b) => { const ra = SOURCE_ORDER.indexOf(a.source); const rb = SOURCE_ORDER.indexOf(b.source); if (ra !== rb) return ra - rb; return a.name.localeCompare(b.name, "pt-BR"); }); return arr; }, [state.trained]); const counts = useMemo(() => { const c = {}; for (const k of SOURCE_ORDER) c[k] = 0; for (const info of Object.values(state.trained)) c[info.source]++; return c; }, [state.trained]); const classSlotsTotal = state.slots_total; const classSlotsUsed = state.slots_used; const intSlotsTotal = state.slots_int; const intSlotsUsed = state.slots_int_used; const hasChoicesToReset = classSlotsUsed > 0 || intSlotsUsed > 0; function onReset() { if (!window.CHAR_SKILLS) return; if (hasChoicesToReset && !confirm("Limpar todas as escolhas de perícia?")) return; window.CHAR_SKILLS.reset(); } return (
Personagem · Perícias nível {level}

Perícias

Treinamento, fonte e bônus calculado de cada perícia do personagem.

{/* Slots */}
{SOURCE_ORDER.map((k) => ( {SOURCE_META[k].short} {counts[k] || 0} ))}
{/* Lista de treinadas */}

Treinadas {trainedList.length}

{trainedList.length === 0 ? (

Nenhuma perícia treinada ainda.

) : (
    {trainedList.map(t => (
  • {t.name} {SOURCE_META[t.source].short}
  • ))}
)}
{/* Busca */}
setFilter(e.target.value)} />
); } function SlotMeter({ label, used, total, tone, empty }) { const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0; return (
{label} {empty ? ( {empty} ) : ( <>{used}/{total} )}
{total > 0 && Array.from({ length: total - 1 }).map((_, i) => ( ))}
); } // ----------------------------------------------------------------------- // Grid principal (página direita) // ----------------------------------------------------------------------- function GridPage({ state, level, totals, filter }) { const all = window.SKILLS || []; /* agrupa por atributo-chave */ const byAttr = useMemo(() => { const m = {}; for (const a of ATTRS) m[a] = []; for (const sk of all) { if (m[sk.key_attribute]) m[sk.key_attribute].push(sk); else (m._other ||= []).push(sk); } return m; }, [all]); if (!all.length) { return (
Perícias
·
Carregando perícias…
); } return (
Atributos · 31 perícias treino +{trainingBonus(level)}
{ATTRS.map(a => ( ))}
); } // ----------------------------------------------------------------------- // ROOT // ----------------------------------------------------------------------- function SkillsCreator() { const state = useSkillsState(); const attrs = useCharAttrs(); const level = useCharLevel(); const [filter, setFilter] = useState(""); if (!state) { return (
·
Carregando…
); } const conflicts = state.conflicts || []; return (
{conflicts.length > 0 && }
); } window.SkillsCreator = SkillsCreator; })();