/* Main app shell — wizard navigation, state, aside summary. */
const { useState: useS, useEffect: useE, useMemo: useM } = React;
const STEPS = [
{ id: "clade", name: "Clade", key: "cladeId" },
{ id: "species", name: "Species", key: "speciesId" },
{ id: "class", name: "Calling", key: "classId" },
{ id: "background", name: "History", key: "backgroundId" },
{ id: "stats", name: "Abilities", key: "stats" },
{ id: "skills", name: "Skills", key: "skills" },
{ id: "review", name: "Sign", key: "name" },
];
const App = ({ data, tweaks, setTweaks }) => {
const [step, setStep] = useS(0);
const [state, setState] = useS(() => ({
data,
cladeId: "canidae",
speciesId: data.species.find(s => s.clade_id === "canidae")?.id,
classId: "fangsworn",
backgroundId: "pack_raised",
statMethod: "array",
statPool: STANDARD_ARRAY.map(v => ({ value: v })),
statAssign: {},
statHistory: [],
chosenSkills: [],
name: "",
portraitStyle: "silhouette",
}));
const set = (patch) => setState(s => ({ ...s, ...patch }));
const clade = data.clades.find(c => c.id === state.cladeId);
const species = data.species.find(s => s.id === state.speciesId);
const cls = data.classes.find(c => c.id === state.classId);
const bg = data.backgrounds.find(b => b.id === state.backgroundId);
// When class changes, reset skill picks
useE(() => { set({ chosenSkills: [] }); }, [state.classId]);
// When clade changes, ensure species belongs to it
useE(() => {
if (!species || species.clade_id !== state.cladeId) {
set({ speciesId: data.species.find(s => s.clade_id === state.cladeId)?.id });
}
}, [state.cladeId]);
// Validation per step
const validate = (i) => {
if (i === 0) return state.cladeId ? null : "Pick a clade.";
if (i === 1) return state.speciesId ? null : "Pick a species.";
if (i === 2) return state.classId ? null : "Pick a calling.";
if (i === 3) return state.backgroundId ? null : "Pick a background.";
if (i === 4) return Object.keys(state.statAssign).length === 6 ? null : `Assign all six abilities (${Object.keys(state.statAssign).length}/6).`;
if (i === 5) return state.chosenSkills.length === (cls?.skills_choose || 0) ? null : `Pick exactly ${cls?.skills_choose} skill${cls?.skills_choose>1?"s":""} (${state.chosenSkills.length}/${cls?.skills_choose}).`;
if (i === 6) return state.name.trim() ? null : "Enter a name.";
return null;
};
const stepError = validate(step);
const allValid = STEPS.every((_, i) => !validate(i));
const StepComp = [
Steps.StepClade, Steps.StepSpecies, Steps.StepClass, Steps.StepBackground,
Steps.StepStats, Steps.StepSkills, Steps.StepReview,
][step];
return (
Theriapolis · Codex of Becoming
Folio {romanize(step+1)} of VII — {STEPS[step].name}
Seed · 0x4F2A · Phase V · M2
{STEPS.map((s, i) => {
// A step is locked if any earlier step has unmet requirements.
// The current step is always reachable; earlier steps are always
// reachable (so the user can go back and edit).
const firstIncomplete = STEPS.findIndex((_, j) => validate(j));
const locked = i > step && firstIncomplete !== -1 && firstIncomplete < i;
const isComplete = !validate(i) && i !== step;
return (
{ if (!locked) setStep(i); }}
title={locked ? "Complete earlier folios first" : undefined}
>
{locked ? "✕" : romanize(i+1)}
{s.name}
);
})}
setStep(Math.max(0, step-1))}>← Back
{stepError || (step < 6 ? "Folio complete" : (allValid ? "Ready to sign" : "Some folios remain"))}
{step+1} / 7
{step < STEPS.length - 1 ? (
setStep(step+1)}>Next ›
) : (
alert(`${state.name} steps into Theriapolis. (Confirmed.)`)}>Confirm & Begin
)}
);
};
const Aside = ({ state, set, tweaks, setTweaks }) => {
const clade = state.data.clades.find(c => c.id === state.cladeId);
const species = state.data.species.find(s => s.id === state.speciesId);
const cls = state.data.classes.find(c => c.id === state.classId);
const bg = state.data.backgrounds.find(b => b.id === state.backgroundId);
return (
The Subject
Name
{state.name || "Unnamed"}
Lineage
{species?.name || "—"} {clade?.name} · {SIZE_LABEL[species?.size] || "—"}
{(clade || species) && (
{(clade?.traits || []).map(t => (
))}
{(species?.traits || []).map(t => (
))}
{tweaks.showDetriments && (clade?.detriments || []).map(t => (
))}
{tweaks.showDetriments && (species?.detriments || []).map(t => (
))}
)}
Calling & History
{cls?.name || "—"} d{cls?.hit_die} · {bg?.name || "no history"}
{cls && (
{(cls.level_table?.find(l => l.level === 1)?.features || [])
.filter(k => !["asi","subclass_select","subclass_feature"].includes(k))
.map(k => {
const f = cls.feature_definitions[k];
if (!f) return null;
return ;
})}
)}
{bg && (
)}
Abilities
{ABILITIES.map(ab => {
const base = state.statAssign[ab];
const cm = clade?.ability_mods[ab] || 0;
const sm = species?.ability_mods[ab] || 0;
const f = base != null ? base + cm + sm : null;
const m = f != null ? abilityMod(f) : null;
return (
{ab}
{f ?? "—"}
{m != null ? signed(m) : ""}
);
})}
Skills · {state.chosenSkills.length + (bg?.skill_proficiencies?.length || 0)}
{(bg?.skill_proficiencies || []).map(s => (
))}
{state.chosenSkills.map(s => (
))}
{(state.chosenSkills.length + (bg?.skill_proficiencies?.length || 0)) === 0 && (
none yet
)}
);
};
function romanize(n) {
return ["I","II","III","IV","V","VI","VII","VIII","IX","X"][n-1] || String(n);
}
window.App = App;