/* The 7 wizard step components.
Exports: StepClade, StepSpecies, StepClass, StepBackground, StepStats, StepSkills, StepReview
All read/write through the shared `state` object passed via props. */
const { useState, useMemo, useEffect } = React;
// ============ STEP 1: CLADE ============
const StepClade = ({ state, set, tweaks }) => {
const { clades } = state.data;
const groupBy = tweaks.predatorPrey;
const groups = groupBy
? [
{ label: "Predators", items: clades.filter(c => c.kind === "predator") },
{ label: "Prey", items: clades.filter(c => c.kind === "prey") },
]
: [{ label: null, items: clades }];
const renderCard = (c) => {
const sel = state.cladeId === c.id;
return (
set({ cladeId: c.id, speciesId: state.data.species.find(s => s.clade_id === c.id)?.id })}
>
{Object.entries(c.ability_mods).map(([k,v]) => (
= 0 ? "pos" : "neg")}>{k} {signed(v)}
))}
Languages
{c.languages.map(l => (
))}
Traits
{c.traits.map(t => (
))}
{tweaks.showDetriments && c.detriments.map(t => (
))}
);
};
return (
<>
Folio I — Of Bloodlines
Choose your Clade
The seven great families of Theriapolis. Your clade is the body you were born to — the broad shape of your gait, the fall of your shadow, the words your scent carries before you speak.
{groups.map(g => (
{g.label &&
{g.label}
}
{g.items.map(renderCard)}
))}
>
);
};
// ============ STEP 2: SPECIES ============
const StepSpecies = ({ state, set, tweaks }) => {
const clade = state.data.clades.find(c => c.id === state.cladeId);
const filtered = state.data.species.filter(s => s.clade_id === state.cladeId);
if (!clade) return Pick a clade first.
;
return (
<>
Folio II — Of Lineage within {clade.name}
Choose your Species
Within every clade are kindreds — different statures, ranges, and inheritances. The species refines what the clade began.
{filtered.map(s => {
const sel = state.speciesId === s.id;
return (
set({ speciesId: s.id })}
>
{s.name}
{SIZE_LABEL[s.size] || s.size} · {s.base_speed_ft} ft.
{Object.entries(s.ability_mods).map(([k,v]) => (
= 0 ? "pos" : "neg")}>{k} {signed(v)}
))}
{s.traits.map(t => (
))}
{tweaks.showDetriments && s.detriments.map(t => (
))}
);
})}
>
);
};
// ============ STEP 3: CLASS ============
const StepClass = ({ state, set, tweaks }) => {
const recommendedClasses = useMemo(() => {
const recs = [];
Object.entries(CLASS_CLADE_REC).forEach(([cls, clades]) => {
if (clades.includes(state.cladeId)) recs.push(cls);
});
return new Set(recs);
}, [state.cladeId]);
return (
<>
Folio III — Of Vocations
Choose your Calling
Eight callings exist within the Covenant. Each shapes how you fight, treat, parley, or unmake the world.
{state.data.classes.map(c => {
const sel = state.classId === c.id;
const rec = recommendedClasses.has(c.id);
return (
set({ classId: c.id })}
>
{c.name}
{rec && ★ Suits Clade }
d{c.hit_die} · primary {c.primary_ability.join("/")} · saves {c.saves.join("/")}
{(c.level_table?.find(l => l.level === 1)?.features || [])
.filter(k => !["asi","subclass_select","subclass_feature"].includes(k))
.map(k => {
const f = c.feature_definitions[k];
if (!f) return null;
return ;
})}
Picks {c.skills_choose} skill{c.skills_choose > 1 ? "s" : ""} · armor: {c.armor_proficiencies.join(", ")}
);
})}
>
);
};
// ============ STEP 4: BACKGROUND ============
const StepBackground = ({ state, set, tweaks }) => {
return (
<>
Folio IV — Of Histories
Choose your Background
Where the clade gives you body and the calling gives you craft, the background gives you a past — debts, contacts, scars, the way you sleep.
{state.data.backgrounds.map(b => {
const sel = state.backgroundId === b.id;
return (
set({ backgroundId: b.id })}
>
{b.name}
{tweaks.showFlavor &&
{b.flavor}
}
Feature
Skills
{b.skill_proficiencies.map(s => )}
);
})}
>
);
};
// ============ STEP 5: STATS ============
const StepStats = ({ state, set, tweaks }) => {
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 method = state.statMethod; // "array" | "roll"
// Drag/drop handlers cover three cases:
// pool -> slot (assign; if slot has a value, return that to pool)
// slot -> slot (move; if dest has a value, swap the two)
// slot -> pool (return value to pool)
const handleDrop = (destAbility, payload) => {
const newPool = [...state.statPool];
const newAssign = { ...state.statAssign };
if (payload.from === "pool") {
// If dest already has a value, return it to the pool first.
if (newAssign[destAbility] != null) {
newPool.push({ value: newAssign[destAbility] });
}
newPool.splice(payload.idx, 1);
newAssign[destAbility] = payload.value;
} else if (payload.from === "slot") {
const srcAbility = payload.ability;
if (srcAbility === destAbility) return;
const srcVal = newAssign[srcAbility];
const destVal = newAssign[destAbility];
// swap (or move if dest empty)
newAssign[destAbility] = srcVal;
if (destVal != null) newAssign[srcAbility] = destVal;
else delete newAssign[srcAbility];
}
set({ statPool: newPool, statAssign: newAssign });
};
// Drop a slot value back into the pool.
const dropToPool = (payload) => {
if (payload.from !== "slot") return;
const newAssign = { ...state.statAssign };
const v = newAssign[payload.ability];
if (v == null) return;
delete newAssign[payload.ability];
set({
statPool: [...state.statPool, { value: v }],
statAssign: newAssign,
});
};
const clearAbility = (ab) => {
const newPool = [...state.statPool];
const newAssign = { ...state.statAssign };
if (newAssign[ab] != null) {
newPool.push({ value: newAssign[ab], used: false });
delete newAssign[ab];
}
set({ statPool: newPool, statAssign: newAssign });
};
const reroll = () => {
const r = () => {
const dice = Array.from({length: 4}, () => 1 + Math.floor(Math.random() * 6));
dice.sort((a,b) => a-b);
return dice[1] + dice[2] + dice[3];
};
const vals = Array.from({length: 6}, r);
set({
statPool: vals.map(v => ({ value: v })),
statAssign: {},
statHistory: [...(state.statHistory || []), { vals, ts: Date.now() }],
});
};
const useArray = () => {
set({
statMethod: "array",
statPool: STANDARD_ARRAY.map(v => ({ value: v })),
statAssign: {},
});
};
const useRoll = () => {
set({ statMethod: "roll" });
reroll();
};
const autoAssign = () => {
if (!cls) return;
// Honor abilities the user already pinned. Only fill remaining ones from
// values still in the pool, and only into ability slots still empty.
const newAssign = { ...state.statAssign };
const assignedAbilities = new Set(Object.keys(newAssign));
// Order of preference: class primary first (any not yet pinned), then
// a sensible default fallback for the rest.
const order = [...cls.primary_ability];
["CON","DEX","STR","WIS","INT","CHA"].forEach(a => { if (!order.includes(a)) order.push(a); });
const remainingAbilities = order.filter(a => !assignedAbilities.has(a));
// Sort the still-in-pool values descending and place onto remaining
// abilities in preference order.
const remainingValues = [...state.statPool.map(p => p.value)].sort((a,b) => b-a);
const newPool = [];
remainingAbilities.forEach((a, i) => {
if (i < remainingValues.length) {
newAssign[a] = remainingValues[i];
}
});
// If pool had more values than empty slots (shouldn't happen, but be
// defensive), keep the leftovers in the pool.
if (remainingValues.length > remainingAbilities.length) {
remainingValues.slice(remainingAbilities.length).forEach(v => {
newPool.push({ value: v });
});
}
set({ statPool: newPool, statAssign: newAssign });
};
return (
<>
Folio V — Of Aptitudes
Set your Abilities
Six numbers describe what your body and mind can do. Drag values from the pool onto the abilities — your primary calling preference is suggested.
Standard Array
Roll 4d6 — drop lowest
{ e.preventDefault(); e.currentTarget.classList.add("drag-over"); }}
onDragLeave={(e) => e.currentTarget.classList.remove("drag-over")}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove("drag-over");
try {
const payload = JSON.parse(e.dataTransfer.getData("text/plain"));
dropToPool(payload);
} catch {}
}}
>
{state.statPool.length === 0 &&
All values assigned. Drag from a slot to return.
}
{state.statPool.map((p, i) => (
{
e.dataTransfer.setData("text/plain", JSON.stringify({ from: "pool", value: p.value, idx: i }));
e.dataTransfer.effectAllowed = "move";
}}
>{p.value}
))}
{method === "roll" && Reroll }
Auto-assign
{ const ks = Object.keys(state.statAssign); const vals = ks.map(k => state.statAssign[k]); set({ statPool: [...state.statPool, ...vals.map(v => ({value: v}))], statAssign: {}}); }}>Clear
{/* Roll history */}
{method === "roll" && (state.statHistory || []).length > 1 && (
Previous rolls: {" "}
{state.statHistory.slice(0, -1).slice(-3).map((h, i) => (
[{h.vals.join(", ")}]
))}
)}
{ABILITIES.map(ab => {
const v = state.statAssign[ab];
const cladeMod = clade?.ability_mods[ab] || 0;
const speciesMod = species?.ability_mods[ab] || 0;
const totalBonus = cladeMod + speciesMod;
const bonusSources = [];
if (cladeMod) bonusSources.push({ source: clade?.name || "Clade", value: cladeMod });
if (speciesMod) bonusSources.push({ source: species?.name || "Species", value: speciesMod });
const final = (v ?? 0) + cladeMod + speciesMod;
const finalMod = abilityMod(final);
const isPrimary = cls?.primary_ability.includes(ab);
return (
{ab}
{totalBonus !== 0 && (
)}
{ABILITY_LABELS[ab]}{isPrimary && " · primary"}
{
if (v == null) { e.preventDefault(); return; }
e.dataTransfer.setData("text/plain", JSON.stringify({ from: "slot", value: v, ability: ab }));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add("drag-over"); }}
onDragLeave={(e) => e.currentTarget.classList.remove("drag-over")}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove("drag-over");
try {
const payload = JSON.parse(e.dataTransfer.getData("text/plain"));
handleDrop(ab, payload);
} catch {}
}}
onClick={() => v != null && clearAbility(ab)}
title={v != null ? "Drag to swap, or click to return to pool" : "Drop a value here"}
>
{v ?? "—"}
{v != null && (cladeMod || speciesMod) ? `base ${v}` : (v != null ? "" : "")}
{v != null ? final : "—"}
= 0 ? "var(--seal)" : "var(--ink-mute)", letterSpacing: "0.1em"}}>{v != null ? signed(finalMod) : ""}
);
})}
>
);
};
// ============ STEP 6: SKILLS ============
const StepSkills = ({ state, set, tweaks }) => {
const cls = state.data.classes.find(c => c.id === state.classId);
const bg = state.data.backgrounds.find(b => b.id === state.backgroundId);
if (!cls) return Pick a class first.
;
const lockedFromBg = new Set((bg?.skill_proficiencies || []));
const classOptions = new Set(cls.skill_options);
const required = cls.skills_choose;
const chosen = state.chosenSkills;
const toggle = (skillId) => {
if (lockedFromBg.has(skillId)) return;
const has = chosen.includes(skillId);
if (has) set({ chosenSkills: chosen.filter(s => s !== skillId) });
else if (chosen.length < required) set({ chosenSkills: [...chosen, skillId] });
};
// Group all 18 skills by ability
const grouped = {};
ABILITIES.forEach(a => grouped[a] = []);
Object.entries(SKILL_LABEL).forEach(([id]) => {
grouped[SKILL_ABILITY[id]].push(id);
});
return (
<>
Folio VI — Of Trained Hands
Choose your Skills
Your background grants two skills automatically (sealed). From your calling's offered list, choose {required} more.
{chosen.length} / {required} chosen
+ {lockedFromBg.size} sealed by background
Class: {cls.name} · Background: {bg?.name || "—"}
{ABILITIES.map(ab => (
{ABILITY_LABELS[ab]}
{ab}
{grouped[ab].map(skillId => {
const fromBg = lockedFromBg.has(skillId);
const fromClass = classOptions.has(skillId);
const checked = chosen.includes(skillId);
const klass = fromBg ? "locked" : (!fromClass ? "unavailable" : (checked ? "checked" : ""));
return (
fromClass && toggle(skillId)}
>
{(checked || fromBg) ? "✓" : ""}
{fromBg ? "Background" : (fromClass ? "Class" : "—")}
);
})}
))}
>
);
};
// ============ STEP 7: NAME + REVIEW ============
const StepReview = ({ state, set, tweaks, goTo }) => {
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 (
<>
Folio VII — Of Names & Witness
Sign the Codex
Review your character. The name you sign here is the one the world will speak.
Name
set({ name: e.target.value })}
placeholder="Wanderer"
style={{maxWidth: 480}}
/>
{clade?.name}
goTo(0)}>Edit ›
{species?.name} {SIZE_LABEL[species?.size]}
{cls?.name}
goTo(2)}>Edit ›
d{cls?.hit_die} · {cls?.primary_ability.join("/")}
{bg?.name}
Final Abilities
goTo(4)}>Edit ›
{ABILITIES.map(ab => {
const base = state.statAssign[ab] || 0;
const cm = clade?.ability_mods[ab] || 0;
const sm = species?.ability_mods[ab] || 0;
const f = base + cm + sm;
const m = abilityMod(f);
return (
);
})}
Skills
goTo(5)}>Edit ›
{(bg?.skill_proficiencies || []).map(s => (
{SKILL_LABEL[s]} · BG
))}
{state.chosenSkills.map(s => (
{SKILL_LABEL[s]}
))}
Starting Kit
goTo(2)}>Edit ›
{(cls?.starting_kit || []).map((it, i) => (
{ITEM_NAME[it.item_id] || it.item_id}
×{it.qty}
{it.auto_equip &&
{it.equip_slot}
}
))}
>
);
};
window.Steps = { StepClade, StepSpecies, StepClass, StepBackground, StepStats, StepSkills, StepReview };