/* 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 })} >
{c.name}
{c.kind}
{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" && }
{/* 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}

{species?.name} {SIZE_LABEL[species?.size]}

{cls?.name}

d{cls?.hit_die} · {cls?.primary_ability.join("/")}
{bg?.name}

Final Abilities

{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 (
{ab}
{f}
{signed(m)}
); })}

Skills

{(bg?.skill_proficiencies || []).map(s => ( {SKILL_LABEL[s]} · BG ))} {state.chosenSkills.map(s => ( {SKILL_LABEL[s]} ))}

Starting Kit

{(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 };