Initial commit: Theriapolis baseline at port/godot branch point

Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,230 @@
/* 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 (
<div className="app-frame">
<div className="codex-header">
<div>
<h1 className="codex-title">Theriapolis · <em>Codex of Becoming</em></h1>
<div className="codex-sub" style={{marginTop: 6}}>Folio {romanize(step+1)} of VII {STEPS[step].name}</div>
</div>
<div className="codex-sub" style={{textAlign: "right"}}>
Seed · 0x4F2A · Phase V · M2
</div>
</div>
<div className="stepper">
{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 (
<div
key={s.id}
className={"step" + (i === step ? " active" : "") + (isComplete ? " complete" : "") + (locked ? " locked" : "")}
onClick={() => { if (!locked) setStep(i); }}
title={locked ? "Complete earlier folios first" : undefined}
>
<div className="num">{locked ? "✕" : romanize(i+1)}</div>
<div className="name">{s.name}</div>
</div>
);
})}
</div>
<div className="page">
<div className="page-main">
<StepComp state={state} set={set} tweaks={tweaks} goTo={setStep} />
</div>
<div className="page-aside">
<Aside state={state} set={set} tweaks={tweaks} setTweaks={setTweaks} />
</div>
</div>
<div className="nav-bar">
<button className="btn ghost" disabled={step === 0} onClick={() => setStep(Math.max(0, step-1))}> Back</button>
<div style={{display: "flex", alignItems: "center", gap: 24}}>
<div className={"validation" + (stepError ? "" : " ok")}>
{stepError || (step < 6 ? "Folio complete" : (allValid ? "Ready to sign" : "Some folios remain"))}
</div>
<div className="nav-progress">{step+1} / 7</div>
</div>
{step < STEPS.length - 1 ? (
<button className="btn primary" disabled={!!stepError} onClick={() => setStep(step+1)}>Next </button>
) : (
<button className="btn primary" disabled={!allValid} onClick={() => alert(`${state.name} steps into Theriapolis. (Confirmed.)`)}>Confirm & Begin</button>
)}
</div>
</div>
);
};
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 (
<div>
<div className="codex-sub" style={{marginBottom: 10}}>The Subject</div>
<Portrait clade={clade} species={species} style="placeholder" name={state.name} />
<div className="summary-block">
<h4>Name</h4>
<div className={"v" + (!state.name ? " empty" : "")}>{state.name || "Unnamed"}</div>
</div>
<div className="summary-block">
<h4>Lineage</h4>
<div className="v">{species?.name || "—"} <small>{clade?.name} · {SIZE_LABEL[species?.size] || "—"}</small></div>
{(clade || species) && (
<div className="trait-chips" style={{marginTop: 8}}>
{(clade?.traits || []).map(t => (
<TraitName key={"c-"+t.id} trait={t} suffix="" />
))}
{(species?.traits || []).map(t => (
<TraitName key={"s-"+t.id} trait={t} suffix="" />
))}
{tweaks.showDetriments && (clade?.detriments || []).map(t => (
<TraitName key={"cd-"+t.id} trait={t} detriment suffix="" />
))}
{tweaks.showDetriments && (species?.detriments || []).map(t => (
<TraitName key={"sd-"+t.id} trait={t} detriment suffix="" />
))}
</div>
)}
</div>
<div className="summary-block">
<h4>Calling & History</h4>
<div className="v">{cls?.name || "—"} <small>d{cls?.hit_die} · {bg?.name || "no history"}</small></div>
{cls && (
<div className="trait-chips" style={{marginTop: 8}}>
{(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 <TraitName key={"cls-"+k} trait={{ id: cls.id+"_"+k, name: f.name, description: f.description, tag: f.kind }} suffix="" />;
})}
</div>
)}
{bg && (
<div className="trait-chips" style={{marginTop: 6}}>
<TraitName trait={{ id: bg.id+"_feat", name: bg.feature_name, description: bg.feature_description, tag: "feature" }} suffix="" />
</div>
)}
</div>
<div className="summary-block">
<h4>Abilities</h4>
<div className="stat-strip">
{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 (
<div className="cell" key={ab}>
<div className="ab">{ab}</div>
<div className="sc">{f ?? "—"}</div>
<div className={"md" + (m != null && m < 0 ? " neg" : "")}>{m != null ? signed(m) : ""}</div>
</div>
);
})}
</div>
</div>
<div className="summary-block">
<h4>Skills · {state.chosenSkills.length + (bg?.skill_proficiencies?.length || 0)}</h4>
<div className="trait-chips aside-skills">
{(bg?.skill_proficiencies || []).map(s => (
<SkillChip key={"bg-"+s} id={s} className="from-bg" />
))}
{state.chosenSkills.map(s => (
<SkillChip key={"cls-"+s} id={s} className="from-cls" />
))}
{(state.chosenSkills.length + (bg?.skill_proficiencies?.length || 0)) === 0 && (
<span style={{fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink-mute)", letterSpacing: "0.18em"}}>none yet</span>
)}
</div>
</div>
</div>
);
};
function romanize(n) {
return ["I","II","III","IV","V","VI","VII","VIII","IX","X"][n-1] || String(n);
}
window.App = App;
@@ -0,0 +1,163 @@
/* Data loader + helpers */
const ABILITIES = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
const ABILITY_LABELS = {
STR: "Strength", DEX: "Dexterity", CON: "Constitution",
INT: "Intellect", WIS: "Wisdom", CHA: "Charisma",
};
const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8];
const SKILL_ABILITY = {
acrobatics: "DEX", animal_handling: "WIS", arcana: "INT",
athletics: "STR", deception: "CHA", history: "INT",
insight: "WIS", intimidation: "CHA", investigation: "INT",
medicine: "WIS", nature: "INT", perception: "WIS",
performance: "CHA", persuasion: "CHA", religion: "INT",
sleight_of_hand: "DEX", stealth: "DEX", survival: "WIS",
};
const SKILL_LABEL = {
acrobatics: "Acrobatics", animal_handling: "Animal Handling", arcana: "Arcana",
athletics: "Athletics", deception: "Deception", history: "History",
insight: "Insight", intimidation: "Intimidation", investigation: "Investigation",
medicine: "Medicine", nature: "Nature", perception: "Perception",
performance: "Performance", persuasion: "Persuasion", religion: "Religion",
sleight_of_hand: "Sleight of Hand", stealth: "Stealth", survival: "Survival",
};
// Skill descriptions, framed in Theriapolis's clade-vocabulary so hover hints
// match the rest of the codex's tone.
const SKILL_DESC = {
acrobatics: "Tumbling, balance, and the kind of footwork that keeps you upright on a coliseum sand-floor or a warren-rope. Body-cunning under pressure.",
animal_handling: "Reading and steering non-sentient beasts — feral hounds, draft-kine, the wild cousins of your own clade. Calming, herding, riding.",
arcana: "Knowledge of the older magics: scent-sorcery, blood-sigil, the half-forgotten rites that pre-date the Covenant of Claws.",
athletics: "Raw physical effort. Climbing scaffold, swimming the foul canal, hauling a packmate from the pit, breaking a hold.",
deception: "Speaking convincingly past your scent. The art of the false posture, the planted rumor, the answer that is technically true.",
history: "The long memory of Theriapolis — the Imperium's fall, the Compact's ratification, which clade owes which other a centuries-old debt.",
insight: "Reading another's true posture beneath their words. Catching the off-note in a snarl, the held breath, the lie in a friendly tail.",
intimidation: "Bared-teeth diplomacy. The threat made plain enough that violence is not required to extract compliance.",
investigation: "Methodical search and inference: scene-reading, document-sifting, the patient accumulation of small facts into a verdict.",
medicine: "Field surgery, poultice-craft, knowing which clade tolerates which tincture. Stabilizing the dying without finishing them.",
nature: "Knowledge of the wild outside the city wall — terrain, weather, plant-lore, and the unsigned beasts that observe no Covenant.",
perception: "Awareness through every sense your clade gives you: ear-cock, scent-prickle, the half-glimpsed shape at the edge of vision.",
performance: "Holding an audience — coliseum crowd, courtroom gallery, market square. Song, oratory, the body that compels watching.",
persuasion: "Open-handed argument. The case made on its merits, the appeal to mutual benefit, the patient construction of agreement.",
religion: "The hymn-cycles of the Cervid liturgy, the Compact's sacred clauses, the small household rites your clade keeps without thinking.",
sleight_of_hand: "Quiet fingers — pickpocketing, palmed coins, the swap performed under another's nose. Useful in markets and courtrooms alike.",
stealth: "Movement unseen and unsmelled. Wind-checking, scent-suppression, the slow weight-shift on a creaking floor.",
survival: "Field-craft beyond the wall: tracking, foraging, fire-making, knowing which run-off is safe to drink and which carries the upstream butcher's leavings.",
};
const SIZE_LABEL = {
small: "Small", medium: "Medium", medium_large: "Medium-Large", large: "Large",
};
// Class → recommended clades (informational, per design intent)
const CLASS_CLADE_REC = {
fangsworn: ["canidae", "felidae", "ursidae"],
bulwark: ["bovidae", "ursidae"],
feral: ["ursidae", "mustelidae", "bovidae"],
shadow_pelt: ["felidae", "mustelidae", "leporidae"],
scent_broker: ["canidae", "mustelidae"],
covenant_keeper: ["canidae", "bovidae", "cervidae"],
muzzle_speaker: ["felidae", "leporidae"],
claw_wright: ["mustelidae", "leporidae"],
};
// Language metadata — name + description for hover hints
const LANGUAGES = {
common: { name: "Common", description: "The market-and-courthouse trade tongue of Theriapolis. Spoken by every clade; the language of contracts, guard-watches, and most of the city's signage." },
canid: { name: "Canid", description: "Pack-tongue of the Canidae. Heavy with subsonic registers and scent-words — Canid sentences carry pheromonal undertones non-Canid speakers cannot fully parse." },
felid: { name: "Felid", description: "Sinuous and tonal, with a parallel tail-and-ear pidgin. Felid speakers trade in implication and pause; lying in Felid is a high art." },
mustelid: { name: "Mustelid", description: "Quick, percussive trade-speech of the Mustelidae. Famous for its dense vocabulary of musks, debts, and small grievances." },
ursid: { name: "Ursid", description: "Slow, low-register growl-speech. Ursid grammar prefers final emphasis — the important word always comes last." },
cervid: { name: "Cervid", description: "Old, hymn-shaped tongue of the Cervidae. Most speakers know Cervid as a song-language for funerals, treaties, and the long calendar." },
bovid: { name: "Bovid", description: "Patient, formal speech of the herd-clades. Bovid is the language of guild-councils and oaths; lying in formal Bovid is itself a punishable act." },
leporid: { name: "Leporid", description: "Rapid, twitch-paced chatter of the Leporidae. Leporid uses tense markers for danger and runs faster than most non-Leporidae can follow." },
};
// Pretty item names from item ids
const ITEM_NAME = {
rend_sword: "Rend-sword", chain_shirt: "Chain Shirt", buckler: "Buckler",
healers_kit: "Healer's Kit", rations_predator: "Rations (predator)",
rations_prey: "Rations (prey)", hoof_club: "Hoof Club", chain_mail: "Chain Mail",
standard_shield: "Shield", paw_axe: "Paw Axe", hide_vest: "Hide Vest",
thorn_blade: "Thorn-blade", studded_leather: "Studded Leather",
claw_bow: "Claw-bow", poultice_universal: "Universal Poultice",
scent_mask_basic: "Scent-mask", fang_knife: "Fang Knife",
leather_harness: "Leather Harness", pheromone_vial_calm: "Pheromone Vial (calm)",
pheromone_vial_fear: "Pheromone Vial (fear)", rope_claw_braid: "Claw-braid Rope",
};
// Plain-language readings for traits/detriments — author-curated
const TRAIT_READING = {
pack_instinct: "When a friend nearby gets attacked, you can throw your shoulder in front of them to ward off the blow.",
superior_scent: "Your nose tells you what eyes can't — feelings, lies, fear in a room.",
subsonic_communication: "You can talk silently with other Canid-folk over short distances.",
pack_dependent: "Alone, your nerves fray. Crowds steady you.",
scent_overload: "Strong smells make a noisy room.",
retractable_claws: "Sheath them for fine work, draw them for a fight.",
darkvision: "Dim light reads as bright; pitch dark reads as dim grey.",
feline_grace: "You shrug off most falls and find your feet on any ledge.",
tail_speak: "Your tail says what your mouth won't — visible to anyone who reads Felid.",
solitary_instinct: "Help from non-Felidae mostly bounces off — you work alone or you work with kin.",
prides_cost: "A public fumble costs you the next charm check; the room is watching.",
sinuous_frame: "Bend through gaps the size of a saucer; slip a grapple like water.",
burning_metabolism: "Cold doesn't stick. Hunger does.",
ferocity: "Wounded, you bite harder for one turn.",
high_metabolism: "Two days of rations a day — or you crash.",
scent_marker: "Mustelid musk is unmistakable. Stealth costs extra.",
powerful_build: "You count as one size larger for hauling and grappling.",
thick_hide: "Your skin is armor. Blunt damage barely lands.",
bone_crushing_jaws: "A clean bite ends fights.",
lumbering: "You don't sneak. You arrive.",
heat_intolerance: "Heat saps you — long hot days demand rest.",
fleet_footed: "You're faster, and dashing past enemies is safer.",
antlers: "A natural weapon, growing back each year.",
wide_field_of_view: "Hard to flank — you see what's at the corners.",
flight_response: "Sudden danger triggers a save-or-flee reflex.",
delicate_frame: "Less HP per level. You feel hits.",
horns: "Permanent natural weapon — bone, not bone-spurs.",
herd_wall: "Allies at your shoulder give you AC.",
unshakeable: "Fear and charm slide off you.",
ponderous_gait: "Slower base speed; quick pivots aren't your thing.",
stubborn: "Feints fool you because you commit.",
leaping_strides: "No run-up needed for big jumps.",
burrow_savvy: "Underground is home. You see and survive there.",
twitch_reflexes: "First in initiative; ranged shots flinch off.",
fragile_body: "Less HP. Easier to knock down.",
constant_vigilance: "New places keep you wired — short rests don't take.",
};
async function loadData() {
const fetchJson = async (p) => {
const r = await fetch(p);
if (!r.ok) throw new Error("Failed to load " + p);
return r.json();
};
const [clades, species, classes, backgrounds] = await Promise.all([
fetchJson("data/clades.json"),
fetchJson("data/species.json"),
fetchJson("data/classes.json"),
fetchJson("data/backgrounds.json"),
]);
return { clades, species, classes, backgrounds };
}
function abilityMod(score) { return Math.floor((score - 10) / 2); }
function signed(n) { return n >= 0 ? `+${n}` : `${n}`; }
window.ABILITIES = ABILITIES;
window.ABILITY_LABELS = ABILITY_LABELS;
window.STANDARD_ARRAY = STANDARD_ARRAY;
window.SKILL_ABILITY = SKILL_ABILITY;
window.SKILL_LABEL = SKILL_LABEL;
window.SKILL_DESC = SKILL_DESC;
window.SIZE_LABEL = SIZE_LABEL;
window.CLASS_CLADE_REC = CLASS_CLADE_REC;
window.ITEM_NAME = ITEM_NAME;
window.LANGUAGES = LANGUAGES;
window.TRAIT_READING = TRAIT_READING;
window.loadData = loadData;
window.abilityMod = abilityMod;
window.signed = signed;
@@ -0,0 +1,32 @@
/* Entry point — loads data, mounts app. */
const Root = () => {
const [data, setData] = React.useState(null);
const [err, setErr] = React.useState(null);
const [tweaks, setTweak] = useTweaks(window.TweakDefaults);
React.useEffect(() => {
loadData().then(setData).catch(e => setErr(String(e)));
}, []);
// setTweaks accepts a partial object: {theme: 'dark', density: 'compact'}
const setTweaks = (patch) => {
Object.entries(patch).forEach(([k, v]) => setTweak(k, v));
};
if (err) return <div style={{padding: 40, color: "var(--seal)", fontFamily: "var(--serif-display)"}}>Failed to load codex: {err}</div>;
if (!data) return (
<div style={{padding: 80, textAlign: "center", fontFamily: "var(--serif-display)", fontStyle: "italic", color: "var(--ink-mute)"}}>
Unsealing the codex
</div>
);
return (
<>
<App data={data} tweaks={tweaks} setTweaks={setTweaks} />
<TweaksWiring tweaks={tweaks} setTweaks={setTweaks} />
</>
);
};
ReactDOM.createRoot(document.getElementById("app")).render(<Root />);
@@ -0,0 +1,177 @@
/* Portrait — three swappable styles: silhouette+aura, sigil+heraldry, placeholder slot. */
const Portrait = ({ clade, species, style, name }) => {
const cladeId = clade?.id || "canidae";
const speciesId = species?.id;
const cladeColor = {
canidae: "#8b6a3a", felidae: "#c08a3a", mustelidae: "#74552c",
ursidae: "#5a3a1c", cervidae: "#a07840", bovidae: "#6a4a2a", leporidae: "#b89863",
}[cladeId] || "#8b6a3a";
if (style === "silhouette") {
return (
<div className="portrait-frame" style={{position: "relative"}}>
<SilhouetteSVG cladeId={cladeId} speciesId={speciesId} accent={cladeColor} />
<div style={{position: "absolute", bottom: 12, left: 12, right: 12, fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.2em", textTransform: "uppercase", color: "var(--ink-mute)", textAlign: "center"}}>
{species?.name || "—"} · {clade?.name || "—"}
</div>
</div>
);
}
if (style === "heraldry") {
return (
<div className="portrait-frame" style={{position: "relative"}}>
<svg viewBox="0 0 100 110" style={{width: "85%", height: "auto"}}>
{/* Heraldic shield */}
<defs>
<linearGradient id="hg" x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor="rgba(255,250,235,0.16)" />
<stop offset="1" stopColor="rgba(0,0,0,0.18)" />
</linearGradient>
</defs>
<path d="M10 15 L90 15 L90 60 Q90 95 50 105 Q10 95 10 60 Z" fill={cladeColor} stroke="var(--ink)" strokeWidth="0.8" opacity="0.9"/>
<path d="M10 15 L90 15 L90 60 Q90 95 50 105 Q10 95 10 60 Z" fill="url(#hg)" />
{/* Quarter divisions */}
<path d="M50 15 V105 M10 55 H90" stroke="var(--ink)" strokeWidth="0.4" opacity="0.5" />
{/* Center sigil */}
<g transform="translate(34, 38) scale(1.0)" style={{color: "var(--bg)"}}>
<Sigil id={cladeId} size={32}/>
</g>
</svg>
<div style={{position: "absolute", bottom: 12, left: 12, right: 12, fontFamily: "var(--serif-display)", fontSize: 14, fontStyle: "italic", color: "var(--ink-soft)", textAlign: "center"}}>
House of {clade?.name || "—"}
</div>
</div>
);
}
// placeholder
return (
<div className="portrait-frame">
<div style={{
width: "82%", aspectRatio: "1 / 1",
border: "1.5px dashed var(--rule)",
display: "grid", placeItems: "center", textAlign: "center",
fontFamily: "var(--mono)", fontSize: 11, letterSpacing: "0.2em",
textTransform: "uppercase", color: "var(--ink-mute)",
background: "repeating-linear-gradient(45deg, transparent 0 8px, rgba(0,0,0,0.04) 8px 9px)",
}}>
<div>
<div style={{fontFamily: "var(--serif-display)", fontSize: 24, color: "var(--ink-soft)", marginBottom: 6, textTransform: "none", letterSpacing: 0, fontStyle: "italic"}}>portrait</div>
{clade?.name || "—"} / {species?.name || "—"}
<div style={{marginTop: 4, opacity: 0.7}}>tbd · art ticket</div>
</div>
</div>
</div>
);
};
const SilhouetteSVG = ({ cladeId, speciesId, accent }) => {
// Stylized side-profile silhouette per clade family.
// Each ~ figurative anthro-bust suggested through tonal shapes only.
const head = SILHOUETTES[cladeId] || SILHOUETTES.canidae;
return (
<svg viewBox="0 0 100 110" style={{width: "85%", height: "auto"}}>
<defs>
<radialGradient id={"aura-"+cladeId} cx="0.5" cy="0.4" r="0.7">
<stop offset="0" stopColor={accent} stopOpacity="0.35" />
<stop offset="0.6" stopColor={accent} stopOpacity="0.08" />
<stop offset="1" stopColor={accent} stopOpacity="0" />
</radialGradient>
<linearGradient id={"sil-"+cladeId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--ink)" />
<stop offset="1" stopColor="var(--ink-soft)" />
</linearGradient>
</defs>
{/* aura */}
<circle cx="50" cy="55" r="48" fill={"url(#aura-"+cladeId+")"} />
{/* silhouette */}
<g fill={"url(#sil-"+cladeId+")"}>{head}</g>
{/* scent particles */}
<g fill={accent} opacity="0.5">
<circle cx="22" cy="40" r="0.9"><animate attributeName="cy" values="40;28;40" dur="4s" repeatCount="indefinite" /><animate attributeName="opacity" values="0.5;0;0.5" dur="4s" repeatCount="indefinite" /></circle>
<circle cx="78" cy="46" r="1.1"><animate attributeName="cy" values="46;30;46" dur="5s" repeatCount="indefinite" /><animate attributeName="opacity" values="0.4;0;0.4" dur="5s" repeatCount="indefinite" /></circle>
<circle cx="50" cy="22" r="0.7"><animate attributeName="cy" values="22;6;22" dur="6s" repeatCount="indefinite" /><animate attributeName="opacity" values="0.5;0;0.5" dur="6s" repeatCount="indefinite" /></circle>
</g>
</svg>
);
};
// Hand-shaped silhouette paths per clade — abstract anthropomorphic busts
const SILHOUETTES = {
canidae: <>
{/* shoulders */}
<path d="M20 105 Q22 80 35 75 L65 75 Q78 80 80 105 Z" />
{/* neck */}
<path d="M42 78 L58 78 L57 65 L43 65 Z" />
{/* head */}
<path d="M30 50 Q30 30 50 28 Q70 30 70 50 Q70 62 60 65 L40 65 Q30 62 30 50 Z" />
{/* ears */}
<path d="M32 38 L26 22 L36 33 Z" />
<path d="M68 38 L74 22 L64 33 Z" />
{/* muzzle */}
<path d="M50 45 Q60 48 60 55 Q55 60 50 60 Q45 60 40 55 Q40 48 50 45 Z" fill="rgba(0,0,0,0.25)" />
</>,
felidae: <>
<path d="M20 105 Q22 80 35 75 L65 75 Q78 80 80 105 Z" />
<path d="M42 78 L58 78 L57 65 L43 65 Z" />
<path d="M28 52 Q28 30 50 28 Q72 30 72 52 Q72 62 62 65 L38 65 Q28 62 28 52 Z" />
{/* triangular ears */}
<path d="M32 36 L28 18 L40 30 Z" />
<path d="M68 36 L72 18 L60 30 Z" />
<path d="M50 50 Q56 52 56 56 Q53 60 50 60 Q47 60 44 56 Q44 52 50 50 Z" fill="rgba(0,0,0,0.25)" />
</>,
mustelidae: <>
<path d="M22 105 Q24 82 36 78 L64 78 Q76 82 78 105 Z" />
<path d="M44 80 L56 80 L55 68 L45 68 Z" />
{/* long narrow head */}
<path d="M32 52 Q32 32 50 30 Q68 32 68 52 Q68 64 60 68 L40 68 Q32 64 32 52 Z" />
<path d="M34 38 L30 26 L40 34 Z" />
<path d="M66 38 L70 26 L60 34 Z" />
<path d="M50 48 Q58 51 58 56 Q53 60 50 60 Q47 60 42 56 Q42 51 50 48 Z" fill="rgba(0,0,0,0.25)" />
</>,
ursidae: <>
<path d="M14 105 Q18 78 32 72 L68 72 Q82 78 86 105 Z" />
<path d="M40 76 L60 76 L58 64 L42 64 Z" />
{/* round large head */}
<path d="M22 50 Q22 26 50 24 Q78 26 78 50 Q78 64 66 68 L34 68 Q22 64 22 50 Z" />
{/* small round ears */}
<circle cx="28" cy="26" r="6" />
<circle cx="72" cy="26" r="6" />
<path d="M50 48 Q60 52 60 58 Q54 62 50 62 Q46 62 40 58 Q40 52 50 48 Z" fill="rgba(0,0,0,0.25)" />
</>,
cervidae: <>
<path d="M22 105 Q24 82 36 76 L64 76 Q76 82 78 105 Z" />
<path d="M44 78 L56 78 L55 64 L45 64 Z" />
<path d="M32 52 Q32 30 50 28 Q68 30 68 52 Q68 62 60 64 L40 64 Q32 62 32 52 Z" />
{/* antlers */}
<path d="M40 32 L34 14 M34 14 L28 18 M34 14 L36 8" stroke="var(--ink)" strokeWidth="1.6" fill="none" strokeLinecap="round"/>
<path d="M60 32 L66 14 M66 14 L72 18 M66 14 L64 8" stroke="var(--ink)" strokeWidth="1.6" fill="none" strokeLinecap="round"/>
<path d="M40 32 L36 26 M60 32 L64 26" stroke="var(--ink)" strokeWidth="1.4" fill="none" strokeLinecap="round" />
<path d="M50 48 Q56 51 56 55 Q53 58 50 58 Q47 58 44 55 Q44 51 50 48 Z" fill="rgba(0,0,0,0.25)" />
</>,
bovidae: <>
<path d="M16 105 Q20 80 34 74 L66 74 Q80 80 84 105 Z" />
<path d="M42 78 L58 78 L57 64 L43 64 Z" />
<path d="M26 52 Q26 28 50 26 Q74 28 74 52 Q74 64 64 66 L36 66 Q26 64 26 52 Z" />
{/* curved horns */}
<path d="M28 32 Q12 28 14 18" stroke="var(--ink)" strokeWidth="2" fill="none" strokeLinecap="round" />
<path d="M72 32 Q88 28 86 18" stroke="var(--ink)" strokeWidth="2" fill="none" strokeLinecap="round" />
<path d="M50 48 Q58 51 58 56 Q53 60 50 60 Q47 60 42 56 Q42 51 50 48 Z" fill="rgba(0,0,0,0.25)" />
</>,
leporidae: <>
<path d="M22 105 Q24 84 36 78 L64 78 Q76 84 78 105 Z" />
<path d="M44 80 L56 80 L55 66 L45 66 Z" />
<path d="M32 56 Q32 38 50 36 Q68 38 68 56 Q68 64 60 66 L40 66 Q32 64 32 56 Z" />
{/* long ears */}
<ellipse cx="42" cy="20" rx="3.5" ry="14" />
<ellipse cx="58" cy="20" rx="3.5" ry="14" />
<ellipse cx="42" cy="22" rx="1.6" ry="9" fill={"rgba(0,0,0,0.25)"} />
<ellipse cx="58" cy="22" rx="1.6" ry="9" fill={"rgba(0,0,0,0.25)"} />
<path d="M50 52 Q55 54 55 58 Q52 60 50 60 Q48 60 45 58 Q45 54 50 52 Z" fill="rgba(0,0,0,0.25)" />
</>,
};
window.Portrait = Portrait;
@@ -0,0 +1,28 @@
/* Clade & class sigils — simple geometric heraldic glyphs (not figurative). */
const Sigil = ({ id, size = 30 }) => {
const s = size;
const stroke = "currentColor";
const sw = 1.4;
const common = { width: s, height: s, viewBox: "0 0 32 32", fill: "none", stroke, strokeWidth: sw, strokeLinecap: "round", strokeLinejoin: "round" };
switch (id) {
case "canidae": // pack triangle (three points)
return <svg {...common}><path d="M16 5 L27 26 L5 26 Z" /><circle cx="16" cy="18" r="2.2" /><path d="M11 21 L8 26 M21 21 L24 26" /></svg>;
case "felidae": // crescent + claw
return <svg {...common}><path d="M22 6 a10 10 0 1 0 4 14 a8 8 0 1 1 -4 -14 Z" /><path d="M9 22 L13 18 M13 22 L16 19" /></svg>;
case "mustelidae": // sinuous "S"
return <svg {...common}><path d="M8 7 q6 0 6 6 t6 6 q6 0 6 6" /><circle cx="6" cy="7" r="1.2" fill={stroke} /><circle cx="26" cy="25" r="1.2" fill={stroke} /></svg>;
case "ursidae": // heavy paw
return <svg {...common}><circle cx="16" cy="20" r="6" /><circle cx="9" cy="13" r="2.2" /><circle cx="16" cy="9" r="2.2" /><circle cx="23" cy="13" r="2.2" /></svg>;
case "cervidae": // antler crown
return <svg {...common}><path d="M16 28 V16" /><path d="M16 16 L9 9 M9 9 L6 11 M9 9 L9 5" /><path d="M16 16 L23 9 M23 9 L26 11 M23 9 L23 5" /><path d="M16 16 L13 12 M16 16 L19 12" /></svg>;
case "bovidae": // horns
return <svg {...common}><path d="M16 22 V14" /><path d="M16 14 q-8 -2 -10 -8 q4 0 7 4 q1 2 3 4" /><path d="M16 14 q8 -2 10 -8 q-4 0 -7 4 q-1 2 -3 4" /><circle cx="16" cy="24" r="3" /></svg>;
case "leporidae": // long ears
return <svg {...common}><ellipse cx="12" cy="10" rx="2" ry="6" /><ellipse cx="20" cy="10" rx="2" ry="6" /><circle cx="16" cy="22" r="5" /><circle cx="14" cy="21" r="0.6" fill={stroke} /><circle cx="18" cy="21" r="0.6" fill={stroke} /></svg>;
default:
return <svg {...common}><circle cx="16" cy="16" r="9" /></svg>;
}
};
window.Sigil = Sigil;
@@ -0,0 +1,647 @@
/* 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 (
<div
key={c.id}
className={"card scented" + (sel ? " selected" : "")}
onClick={() => set({ cladeId: c.id, speciesId: state.data.species.find(s => s.clade_id === c.id)?.id })}
>
<span className="scent-aura" />
<div style={{display: "flex", alignItems: "flex-start", gap: 14}}>
<div className="sigil"><Sigil id={c.id} /></div>
<div style={{flex: 1}}>
<div className="name">{c.name}</div>
<div className="meta">{c.kind}</div>
</div>
</div>
<div className="mods">
{Object.entries(c.ability_mods).map(([k,v]) => (
<span key={k} className={"mod " + (v >= 0 ? "pos" : "neg")}>{k} {signed(v)}</span>
))}
</div>
<div className="trait-chips-label">Languages</div>
<div className="trait-chips lang-chips">
{c.languages.map(l => (
<LanguageChip key={l} id={l} />
))}
</div>
<div className="trait-chips-label">Traits</div>
<div className="trait-chips">
{c.traits.map(t => (
<TraitName key={t.id} trait={t} suffix="" />
))}
{tweaks.showDetriments && c.detriments.map(t => (
<TraitName key={t.id} trait={t} detriment suffix="" />
))}
</div>
</div>
);
};
return (
<>
<div className="page-intro">
<div>
<div className="eyebrow">Folio I Of Bloodlines</div>
<h2>Choose your Clade</h2>
<p>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.</p>
</div>
</div>
{groups.map(g => (
<div key={g.label || "all"}>
{g.label && <div className="clade-group-label">{g.label}</div>}
<div className="card-grid">{g.items.map(renderCard)}</div>
</div>
))}
</>
);
};
// ============ 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 <p>Pick a clade first.</p>;
return (
<>
<div className="page-intro">
<div>
<div className="eyebrow">Folio II Of Lineage within {clade.name}</div>
<h2>Choose your Species</h2>
<p>Within every clade are kindreds different statures, ranges, and inheritances. The species refines what the clade began.</p>
</div>
</div>
<div className="card-grid">
{filtered.map(s => {
const sel = state.speciesId === s.id;
return (
<div
key={s.id}
className={"card" + (sel ? " selected" : "")}
onClick={() => set({ speciesId: s.id })}
>
<div className="name">{s.name}</div>
<div className="meta">{SIZE_LABEL[s.size] || s.size} · {s.base_speed_ft} ft.</div>
<div className="mods">
{Object.entries(s.ability_mods).map(([k,v]) => (
<span key={k} className={"mod " + (v >= 0 ? "pos" : "neg")}>{k} {signed(v)}</span>
))}
</div>
<div className="trait-chips" style={{marginTop: 12}}>
{s.traits.map(t => (
<TraitName key={t.id} trait={t} suffix="" />
))}
{tweaks.showDetriments && s.detriments.map(t => (
<TraitName key={t.id} trait={t} detriment suffix="" />
))}
</div>
</div>
);
})}
</div>
</>
);
};
// ============ 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 (
<>
<div className="page-intro">
<div>
<div className="eyebrow">Folio III Of Vocations</div>
<h2>Choose your Calling</h2>
<p>Eight callings exist within the Covenant. Each shapes how you fight, treat, parley, or unmake the world.</p>
</div>
</div>
<div className="card-grid">
{state.data.classes.map(c => {
const sel = state.classId === c.id;
const rec = recommendedClasses.has(c.id);
return (
<div
key={c.id}
className={"card" + (sel ? " selected" : "")}
onClick={() => set({ classId: c.id })}
>
<div className="name">
{c.name}
{rec && <span className="badge-rec" title="Suits your clade"> Suits Clade</span>}
</div>
<div className="meta">d{c.hit_die} · primary {c.primary_ability.join("/")} · saves {c.saves.join("/")}</div>
<div className="trait-chips" style={{marginTop: 12}}>
{(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 <TraitName key={k} trait={{ id: c.id+"_"+k, name: f.name, description: f.description, tag: f.kind }} suffix="" />;
})}
</div>
<div style={{marginTop: 10, fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.16em", color: "var(--ink-mute)", textTransform: "uppercase"}}>
Picks {c.skills_choose} skill{c.skills_choose > 1 ? "s" : ""} · armor: {c.armor_proficiencies.join(", ")}
</div>
</div>
);
})}
</div>
</>
);
};
// ============ STEP 4: BACKGROUND ============
const StepBackground = ({ state, set, tweaks }) => {
return (
<>
<div className="page-intro">
<div>
<div className="eyebrow">Folio IV Of Histories</div>
<h2>Choose your Background</h2>
<p>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.</p>
</div>
</div>
<div className="card-grid">
{state.data.backgrounds.map(b => {
const sel = state.backgroundId === b.id;
return (
<div
key={b.id}
className={"card bg-card" + (sel ? " selected" : "")}
onClick={() => set({ backgroundId: b.id })}
>
<div className="name">{b.name}</div>
{tweaks.showFlavor && <div className="flavor">{b.flavor}</div>}
<div className="trait-chips-label" style={{marginTop: 10}}>Feature</div>
<div className="trait-chips bg-feat-row">
<TraitName trait={{ id: b.id+"_feat", name: b.feature_name, description: b.feature_description, tag: "feature" }} suffix="" />
</div>
<div className="trait-chips-label" style={{marginTop: 10}}>Skills</div>
<div className="trait-chips">
{b.skill_proficiencies.map(s => <SkillChip key={s} id={s} />)}
</div>
</div>
);
})}
</div>
</>
);
};
// ============ 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 (
<>
<div className="page-intro">
<div>
<div className="eyebrow">Folio V Of Aptitudes</div>
<h2>Set your Abilities</h2>
<p>Six numbers describe what your body and mind can do. Drag values from the pool onto the abilities your primary calling preference is suggested.</p>
</div>
</div>
<div className="stat-tabs">
<div className={"stat-tab" + (method === "array" ? " active" : "")} onClick={useArray}>Standard Array</div>
<div className={"stat-tab" + (method === "roll" ? " active" : "")} onClick={useRoll}>Roll 4d6 drop lowest</div>
</div>
<div
className="pool"
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"));
dropToPool(payload);
} catch {}
}}
>
{state.statPool.length === 0 && <div style={{fontFamily: "var(--mono)", fontSize: 11, letterSpacing: "0.18em", color: "var(--ink-mute)", textTransform: "uppercase"}}>All values assigned. Drag from a slot to return.</div>}
{state.statPool.map((p, i) => (
<div
key={i+"-"+p.value}
className="die"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", JSON.stringify({ from: "pool", value: p.value, idx: i }));
e.dataTransfer.effectAllowed = "move";
}}
>{p.value}</div>
))}
<div style={{marginLeft: "auto", display: "flex", gap: 8, alignItems: "center"}}>
{method === "roll" && <button className="btn small ghost" onClick={reroll}>Reroll</button>}
<button className="btn small ghost" onClick={autoAssign} disabled={state.statPool.length === 0}>Auto-assign</button>
<button className="btn small ghost" onClick={() => { 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</button>
</div>
</div>
{/* Roll history */}
{method === "roll" && (state.statHistory || []).length > 1 && (
<div style={{marginBottom: 18, fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-mute)"}}>
<strong style={{fontFamily: "var(--serif-display)", fontStyle: "italic", fontSize: 13, color: "var(--ink-soft)", textTransform: "none", letterSpacing: 0}}>Previous rolls:</strong>{" "}
{state.statHistory.slice(0, -1).slice(-3).map((h, i) => (
<span key={i} style={{marginRight: 12}}>[{h.vals.join(", ")}]</span>
))}
</div>
)}
<div>
{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 (
<div className="ability-row" key={ab}>
<div className="ab-name">
<span style={{display: "inline-flex", alignItems: "center", gap: 6}}>
{ab}
{totalBonus !== 0 && (
<BonusPill total={totalBonus} sources={bonusSources} ability={ab} />
)}
</span>
<small>{ABILITY_LABELS[ab]}{isPrimary && " · primary"}</small>
</div>
<div
className={"slot" + (v != null ? " filled" : "")}
draggable={v != null}
onDragStart={(e) => {
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 ?? "—"}
</div>
<div className="formula">
{v != null && (cladeMod || speciesMod) ? `base ${v}` : (v != null ? "" : "")}
</div>
<div style={{display: "flex", flexDirection: "column"}}>
<div className="ab-name" style={{fontSize: 22}}>
{v != null ? final : "—"}
<small style={{color: finalMod >= 0 ? "var(--seal)" : "var(--ink-mute)", letterSpacing: "0.1em"}}>{v != null ? signed(finalMod) : ""}</small>
</div>
</div>
<div className="bar">
<div className="bar-fill" style={{width: Math.max(0, Math.min(100, (final/20)*100)) + "%"}} />
</div>
</div>
);
})}
</div>
</>
);
};
// ============ 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 <p>Pick a class first.</p>;
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 (
<>
<div className="page-intro">
<div>
<div className="eyebrow">Folio VI Of Trained Hands</div>
<h2>Choose your Skills</h2>
<p>Your background grants two skills automatically (sealed). From your calling's offered list, choose {required} more.</p>
</div>
</div>
<div className="skills-meta">
<div>
<div className="count"><em>{chosen.length}</em> / {required} chosen</div>
<div style={{fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.2em", color: "var(--ink-mute)", textTransform: "uppercase", marginTop: 2}}>
+ {lockedFromBg.size} sealed by background
</div>
</div>
<div style={{fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.18em", color: "var(--ink-mute)", textTransform: "uppercase"}}>
Class: {cls.name} · Background: {bg?.name || "—"}
</div>
</div>
<div className="skill-grid-by-ability">
{ABILITIES.map(ab => (
<div className="skill-group" key={ab}>
<h5>
{ABILITY_LABELS[ab]}
<small>{ab}</small>
</h5>
{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 (
<div
key={skillId}
className={"skill-row " + klass}
onClick={() => fromClass && toggle(skillId)}
>
<div className="label">
<div className="check">{(checked || fromBg) ? "✓" : ""}</div>
<SkillChip id={skillId} />
</div>
<div className="source-tag">
{fromBg ? "Background" : (fromClass ? "Class" : "—")}
</div>
</div>
);
})}
</div>
))}
</div>
</>
);
};
// ============ 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 (
<>
<div className="page-intro">
<div>
<div className="eyebrow">Folio VII Of Names & Witness</div>
<h2>Sign the Codex</h2>
<p>Review your character. The name you sign here is the one the world will speak.</p>
</div>
</div>
<div style={{marginBottom: 22}}>
<h4 style={{marginBottom: 4}}>Name</h4>
<input
type="text"
value={state.name}
onChange={(e) => set({ name: e.target.value })}
placeholder="Wanderer"
style={{maxWidth: 480}}
/>
</div>
<div className="review-grid">
<div className="review-block">
<div className="head">
<h3 style={{fontStyle: "italic"}}>{clade?.name}</h3>
<button className="edit-link" onClick={() => goTo(0)}>Edit </button>
</div>
<div style={{fontFamily: "var(--serif-display)", fontSize: 18}}>{species?.name} <span style={{color: "var(--ink-mute)", fontSize: 13, fontFamily: "var(--mono)", letterSpacing: "0.16em", textTransform: "uppercase", marginLeft: 8}}>{SIZE_LABEL[species?.size]}</span></div>
</div>
<div className="review-block">
<div className="head">
<h3 style={{fontStyle: "italic"}}>{cls?.name}</h3>
<button className="edit-link" onClick={() => goTo(2)}>Edit </button>
</div>
<div style={{fontFamily: "var(--mono)", fontSize: 11, letterSpacing: "0.18em", color: "var(--ink-mute)", textTransform: "uppercase"}}>
d{cls?.hit_die} · {cls?.primary_ability.join("/")}
</div>
<div style={{fontFamily: "var(--serif-display)", fontStyle: "italic", color: "var(--ink-soft)", marginTop: 6, fontSize: 14}}>
{bg?.name}
</div>
</div>
</div>
<div className="review-block" style={{marginTop: 18}}>
<div className="head">
<h3 style={{fontStyle: "italic"}}>Final Abilities</h3>
<button className="edit-link" onClick={() => goTo(4)}>Edit </button>
</div>
<div className="stat-strip" style={{marginTop: 6}}>
{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 (
<div className="cell" key={ab}>
<div className="ab">{ab}</div>
<div className="sc">{f}</div>
<div className={"md" + (m < 0 ? " neg" : "")}>{signed(m)}</div>
</div>
);
})}
</div>
</div>
<div className="review-block" style={{marginTop: 18}}>
<div className="head">
<h3 style={{fontStyle: "italic"}}>Skills</h3>
<button className="edit-link" onClick={() => goTo(5)}>Edit </button>
</div>
<div className="mods" style={{marginTop: 4}}>
{(bg?.skill_proficiencies || []).map(s => (
<span key={s} className="mod" style={{borderColor: "var(--gild)", color: "var(--gild)"}}>{SKILL_LABEL[s]} · BG</span>
))}
{state.chosenSkills.map(s => (
<span key={s} className="mod pos">{SKILL_LABEL[s]}</span>
))}
</div>
</div>
<div className="review-block" style={{marginTop: 18}}>
<div className="head">
<h3 style={{fontStyle: "italic"}}>Starting Kit</h3>
<button className="edit-link" onClick={() => goTo(2)}>Edit </button>
</div>
<div className="kit-grid" style={{marginTop: 6}}>
{(cls?.starting_kit || []).map((it, i) => (
<div className={"kit-item" + (it.auto_equip ? " equipped" : "")} key={i}>
<div>{ITEM_NAME[it.item_id] || it.item_id}</div>
<div className="qty">×{it.qty}</div>
{it.auto_equip && <div className="equipped-tag">{it.equip_slot}</div>}
</div>
))}
</div>
</div>
</>
);
};
window.Steps = { StepClade, StepSpecies, StepClass, StepBackground, StepStats, StepSkills, StepReview };
@@ -0,0 +1,180 @@
/* TraitName — clickable/hoverable trait name that shows a hint window with description.
Stays open as long as the mouse is over the trait name OR the hint window itself. */
const TraitName = ({ trait, detriment = false, suffix = ".", className = "", label = null }) => {
const [open, setOpen] = React.useState(false);
const [pos, setPos] = React.useState({ left: 0, top: 0, arrowLeft: 18 });
const triggerRef = React.useRef(null);
const hintRef = React.useRef(null);
const closeTimerRef = React.useRef(null);
// We allow a tiny grace period so moving the cursor from the trigger to the
// hint window doesn't cause a flicker-close. Both trigger and hint cancel
// the timer on enter and start it on leave.
const cancelClose = () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
};
const scheduleClose = () => {
cancelClose();
closeTimerRef.current = setTimeout(() => setOpen(false), 80);
};
// Re-measure & clamp. Wrapped so we can call from the layout effect AND
// from scroll/resize. Stored in a ref to avoid re-creating the listener.
const measure = React.useCallback(() => {
if (!hintRef.current || !triggerRef.current) return;
const PAD = 8;
const trig = triggerRef.current.getBoundingClientRect();
const hint = hintRef.current.getBoundingClientRect();
// documentElement.clientWidth excludes the vertical scrollbar gutter,
// which window.innerWidth sometimes includes. Using clientWidth keeps
// the popover from poking into the scrollbar lane.
const vw = document.documentElement.clientWidth || window.innerWidth;
const vh = document.documentElement.clientHeight || window.innerHeight;
// Default: under trigger, left-aligned with trigger.
let left = trig.left;
let top = trig.bottom + 6;
let placement = "below";
// Flip above if there's no room below and there's room above.
if (top + hint.height + PAD > vh && trig.top - 6 - hint.height >= PAD) {
top = trig.top - 6 - hint.height;
placement = "above";
}
// Clamp horizontally inside the viewport
if (left + hint.width + PAD > vw) left = vw - hint.width - PAD;
if (left < PAD) left = PAD;
// Clamp vertically (in case neither above nor below quite fits — better
// to overlap the trigger than escape the viewport).
if (top + hint.height + PAD > vh) top = vh - hint.height - PAD;
if (top < PAD) top = PAD;
// Arrow follows trigger center, clamped within the popover's edges.
const triggerCenter = trig.left + trig.width / 2;
const arrowLeft = Math.max(12, Math.min(hint.width - 24, triggerCenter - left - 6));
setPos({
left: left + window.scrollX,
top: top + window.scrollY,
arrowLeft,
placement,
});
}, []);
// After the hint mounts, measure & clamp.
React.useLayoutEffect(() => {
if (!open) return;
measure();
}, [open, measure]);
React.useEffect(() => () => cancelClose(), []);
// Recalculate on scroll/resize while open.
React.useEffect(() => {
if (!open) return;
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
return () => {
window.removeEventListener("scroll", measure, true);
window.removeEventListener("resize", measure);
};
}, [open, measure]);
const onEnter = () => {
cancelClose();
setOpen(true);
};
const reading = TRAIT_READING[trait.id];
return (
<>
<span
ref={triggerRef}
className={"t-name trait-trigger " + className + (detriment ? " detriment" : "")}
onMouseEnter={onEnter}
onMouseLeave={scheduleClose}
>{label != null ? label : (trait.name + suffix)}</span>
{open && ReactDOM.createPortal(
<div
ref={hintRef}
className={"trait-hint" + (detriment ? " detriment" : "") + (pos.placement === "above" ? " above" : "")}
style={{
left: pos.left,
top: pos.top,
"--arrow-left": pos.arrowLeft + "px",
}}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
>
<div className="trait-hint-name">
{trait.name}
{trait.tag && <span className="trait-hint-tag">{trait.tag}</span>}
{detriment && <span className="trait-hint-tag">detriment</span>}
</div>
<div className="trait-hint-desc">{trait.description}</div>
{reading && (
<div className="trait-hint-reading">{reading}</div>
)}
</div>,
document.body,
)}
</>
);
};
window.TraitName = TraitName;
/* LanguageChip — same hover-popover pattern but for a language id. */
const LanguageChip = ({ id }) => {
const lang = LANGUAGES[id] || { name: id, description: "Unknown tongue." };
// Re-use TraitName by giving it a trait-shaped object.
const trait = { id: "lang_" + id, name: lang.name, description: lang.description };
return <TraitName trait={trait} suffix="" />;
};
window.LanguageChip = LanguageChip;
/* SkillChip — hover-popover for a skill id. Shows label, governing ability,
and the codex-flavored description. */
const SkillChip = ({ id, suffix = "", className = "", labelOverride = null }) => {
const name = SKILL_LABEL[id] || id;
const desc = SKILL_DESC[id] || "A skill of Theriapolis.";
const ab = SKILL_ABILITY[id];
const trait = {
id: "skill_" + id,
name,
description: desc,
tag: ab,
};
return <TraitName trait={trait} suffix={suffix} className={className} label={labelOverride} />;
};
window.SkillChip = SkillChip;
/* BonusPill — small badge showing a numeric bonus, with a hover popover
that lists the sources of the bonus (clade, species, etc.) */
const BonusPill = ({ total, sources, ability }) => {
if (!sources || sources.length === 0) return null;
const breakdown = sources.map(s => `${signed(s.value)} from ${s.source}`).join(" · ");
const trait = {
id: "bonus_" + ability,
name: ability + " modifier",
description: breakdown,
};
return (
<TraitName
trait={trait}
label={signed(total)}
className={"bonus-pill " + (total >= 0 ? "pos" : "neg")}
/>
);
};
window.BonusPill = BonusPill;
@@ -0,0 +1,57 @@
/* Tweaks panel wiring. */
const { useEffect: useTE } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "dark",
"density": "comfortable",
"fontPair": "garamond",
"showFlavor": true,
"showDetriments": true,
"predatorPrey": false,
"plainReadings": false
}/*EDITMODE-END*/;
const FONT_PAIRS = {
garamond: { display: "'Cormorant Garamond', serif", body: "'Crimson Pro', serif" },
spectral: { display: "'Spectral', serif", body: "'Spectral', serif" },
cinzel: { display: "'Cinzel', serif", body: "'EB Garamond', serif" },
uncial: { display: "'Uncial Antiqua', serif", body: "'EB Garamond', serif" },
};
const TweaksWiring = ({ tweaks, setTweaks }) => {
// Apply theme + density + fonts to root
useTE(() => {
document.documentElement.dataset.theme = tweaks.theme;
document.documentElement.dataset.density = tweaks.density;
const pair = FONT_PAIRS[tweaks.fontPair] || FONT_PAIRS.garamond;
document.documentElement.style.setProperty("--serif-display", pair.display);
document.documentElement.style.setProperty("--serif-body", pair.body);
}, [tweaks.theme, tweaks.density, tweaks.fontPair]);
return (
<TweaksPanel title="Tweaks">
<TweakSection label="Appearance">
<TweakRadio label="Theme" value={tweaks.theme} onChange={(v) => setTweaks({ theme: v })}
options={[{value:"parchment", label:"Parchment"}, {value:"dark", label:"Candlelit"}, {value:"blood", label:"Blood-warm"}]} />
<TweakRadio label="Density" value={tweaks.density} onChange={(v) => setTweaks({ density: v })}
options={[{value:"comfortable", label:"Comfortable"}, {value:"compact", label:"Compact"}]} />
<TweakSelect label="Font pairing" value={tweaks.fontPair} onChange={(v) => setTweaks({ fontPair: v })}
options={[
{value:"garamond", label:"Cormorant + Crimson"},
{value:"spectral", label:"Spectral throughout"},
{value:"cinzel", label:"Cinzel + Garamond"},
{value:"uncial", label:"Uncial + Garamond"},
]} />
</TweakSection>
<TweakSection label="Content">
<TweakToggle label="Show flavor text on backgrounds" value={tweaks.showFlavor} onChange={(v) => setTweaks({ showFlavor: v })} />
<TweakToggle label="Show detriments alongside traits" value={tweaks.showDetriments} onChange={(v) => setTweaks({ showDetriments: v })} />
<TweakToggle label="Group clades by predator/prey" value={tweaks.predatorPrey} onChange={(v) => setTweaks({ predatorPrey: v })} />
<TweakToggle label="Plain-language readings on hover" value={tweaks.plainReadings} onChange={(v) => setTweaks({ plainReadings: v })} />
</TweakSection>
</TweaksPanel>
);
};
window.TweakDefaults = TWEAK_DEFAULTS;
window.TweaksWiring = TweaksWiring;