/* 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();
}
}}
>
{/* badge inline na sub-row, evitamos absolute pra não cortar o nome */}
{skill.name}
{skill.armor_penalty && (
)}
{skill.key_attribute}
{meta && (
{meta.short}
)}
{canPickClass && (
disponível
)}
{!canPickClass && canPickInt && (
via INT
)}
{needsTraining && (
só treinada
)}
{trained && !meta && (
treinada
)}
{formatSigned(bonus.total)}
{/* breakdown — sempre presente, revelado em hover/focus via CSS */}
{`⌊${level}/2⌋ = ${bonus.half}`}
+
{`${skill.key_attribute} ${formatSigned(bonus.attr)}`}
{bonus.train > 0 && (<>
+
{`treino +${bonus.train}`}
>)}
=
{formatSigned(bonus.total)}
{needsTraining && (
Só pode ser usada em jogo se treinada (não impede a escolha aqui).
)}
{meta && (
{meta.label}
)}
{openDesc && skill.description && (
{skill.description}
)}
);
}
// -----------------------------------------------------------------------
// 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 (
);
}
return (
Atributos · 31 perícias
treino +{trainingBonus(level)}
);
}
// -----------------------------------------------------------------------
// ROOT
// -----------------------------------------------------------------------
function SkillsCreator() {
const state = useSkillsState();
const attrs = useCharAttrs();
const level = useCharLevel();
const [filter, setFilter] = useState("");
if (!state) {
return (
);
}
const conflicts = state.conflicts || [];
return (
{conflicts.length > 0 &&
}
);
}
window.SkillsCreator = SkillsCreator;
})();