Vibecode central

This commit is contained in:
Alexander Harding
2026-07-31 19:50:55 -04:00
parent 1362f28585
commit a3ea76fdff
67 changed files with 6287 additions and 202 deletions
@@ -0,0 +1,310 @@
"use client";
import { useMemo } from "react";
import { Skill, SKILL_ABILITY } from "@/types/content";
import type { Effect } from "@/types/core";
import { useCreator } from "../CreatorContext";
import { getAbilityModifier } from "@/lib/dndHelpers";
const SKILLS = Object.keys(SKILL_ABILITY) as Skill[];
interface SkillChoice {
featureId: string;
featureName: string;
count: number;
from: Skill[];
}
interface ExpertiseChoice {
featureId: string;
featureName: string;
count: number;
skillPool: Skill[] | "characterSkills";
toolPool: string[];
}
export default function SkillsStep() {
const { draft, update, classes, backgrounds, species, features } = useCreator();
const selectedClass = useMemo(
() => classes.find((c) => c.id === draft.classes[0]?.classId),
[classes, draft.classes]
);
const selectedBackground = useMemo(
() => backgrounds.find((b) => b.id === draft.backgroundId),
[backgrounds, draft.backgroundId]
);
const selectedSpecies = useMemo(
() => species.find((s) => s.id === draft.speciesId),
[species, draft.speciesId]
);
const level = draft.classes[0]?.level ?? 1;
const attachedFeatureIds = useMemo(() => {
const ids: string[] = [];
ids.push(...(selectedSpecies?.featureIds ?? []));
ids.push(...(selectedBackground?.featureIds ?? []));
for (const lf of selectedClass?.levelFeatures ?? []) {
if (lf.level <= level) ids.push(...lf.featureIds);
}
return ids;
}, [selectedSpecies, selectedBackground, selectedClass, level]);
const skillChoices: SkillChoice[] = useMemo(() => {
const out: SkillChoice[] = [];
for (const fid of attachedFeatureIds) {
const f = features[fid];
if (!f) continue;
for (const eff of f.effects ?? []) {
if (eff.target.kind === "skillProficiencyChoice") {
out.push({
featureId: f.id,
featureName: f.name,
count: eff.target.count,
from: eff.target.from,
});
}
}
}
return out;
}, [attachedFeatureIds, features]);
const expertiseChoices: ExpertiseChoice[] = useMemo(() => {
const out: ExpertiseChoice[] = [];
for (const fid of attachedFeatureIds) {
const f = features[fid];
if (!f) continue;
for (const eff of f.effects ?? []) {
if (eff.target.kind === "expertiseChoice") {
out.push({
featureId: f.id,
featureName: f.name,
count: eff.target.count,
skillPool: eff.target.skillPool,
toolPool: eff.target.toolPool,
});
}
}
}
return out;
}, [attachedFeatureIds, features]);
const classSkillOptions = selectedClass?.skillProficiencyOptions?.from ?? [];
const classSkillCount = selectedClass?.skillProficiencyOptions?.count ?? 0;
const backgroundSkills: Skill[] = selectedBackground?.skillProficiencies ?? [];
const featureChosenSkills: Skill[] = useMemo(
() =>
skillChoices.flatMap((c) => (draft.featureChoices[c.featureId] ?? []) as Skill[]),
[skillChoices, draft.featureChoices]
);
const chosen = draft.skillProficiencies;
const chosenFromClass = chosen.filter(
(s) => classSkillOptions.includes(s) && !backgroundSkills.includes(s)
);
const toggleClassSkill = (s: Skill) => {
if (backgroundSkills.includes(s) || featureChosenSkills.includes(s)) return;
if (!classSkillOptions.includes(s)) return;
if (chosen.includes(s)) {
update({ skillProficiencies: chosen.filter((x) => x !== s) });
} else {
if (chosenFromClass.length >= classSkillCount) return;
update({ skillProficiencies: [...chosen, s] });
}
};
const toggleFeatureChoice = (choice: SkillChoice, s: Skill) => {
const current = (draft.featureChoices[choice.featureId] ?? []) as Skill[];
let next: Skill[];
if (current.includes(s)) {
next = current.filter((x) => x !== s);
} else {
if (current.length >= choice.count) return;
next = [...current, s];
}
update({
featureChoices: { ...draft.featureChoices, [choice.featureId]: next },
});
};
const isEffectivelyChosen = (s: Skill) =>
chosen.includes(s) || backgroundSkills.includes(s) || featureChosenSkills.includes(s);
const allCharacterSkills: Skill[] = useMemo(
() => Array.from(new Set([...chosen, ...backgroundSkills, ...featureChosenSkills])),
[chosen, backgroundSkills, featureChosenSkills]
);
const toggleExpertise = (choice: ExpertiseChoice, key: string) => {
const current = (draft.featureChoices[choice.featureId] ?? []) as string[];
let next: string[];
if (current.includes(key)) {
next = current.filter((x) => x !== key);
} else {
if (current.length >= choice.count) return;
next = [...current, key];
}
update({
featureChoices: { ...draft.featureChoices, [choice.featureId]: next },
});
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Skill Proficiencies</h2>
<p className="text-sm text-neutral-400 mt-1">
Class allows {classSkillCount} choice(s). Background + features grant more.
</p>
<p className="text-xs text-neutral-500 mt-1">
Class chosen: {chosenFromClass.length}/{classSkillCount}
</p>
</div>
{skillChoices.map((choice) => {
const picked = (draft.featureChoices[choice.featureId] ?? []) as Skill[];
return (
<div key={choice.featureId} className="p-4 border border-amber-500/40 bg-amber-500/5 rounded">
<div className="text-sm font-semibold text-amber-300 mb-1">
{choice.featureName} pick {choice.count}
<span className="ml-2 text-xs text-neutral-500">({picked.length}/{choice.count})</span>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{choice.from.map((s) => {
const on = picked.includes(s);
const conflictOther =
!on && (chosen.includes(s) || backgroundSkills.includes(s) || featureChosenSkills.includes(s));
const disabled = conflictOther || (!on && picked.length >= choice.count);
return (
<button
key={s}
type="button"
onClick={() => toggleFeatureChoice(choice, s)}
disabled={disabled}
className={`px-2 py-1 text-xs rounded border capitalize transition ${
on
? "bg-amber-500/30 border-amber-400"
: "border-neutral-700 hover:border-neutral-500"
} ${disabled ? "opacity-40 cursor-not-allowed" : ""}`}
>
{s}
</button>
);
})}
</div>
</div>
);
})}
{expertiseChoices.map((choice) => {
const picked = (draft.featureChoices[choice.featureId] ?? []) as string[];
const skillOptions: Skill[] =
choice.skillPool === "characterSkills" ? allCharacterSkills : (choice.skillPool as Skill[]);
return (
<div key={choice.featureId} className="p-4 border border-purple-500/40 bg-purple-500/5 rounded">
<div className="text-sm font-semibold text-purple-300 mb-1">
{choice.featureName} Expertise, pick {choice.count}
<span className="ml-2 text-xs text-neutral-500">({picked.length}/{choice.count})</span>
</div>
{choice.skillPool === "characterSkills" && skillOptions.length === 0 && (
<p className="text-xs text-amber-400 mt-1">Pick skill proficiencies below first.</p>
)}
<div className="mt-2 space-y-2">
{skillOptions.length > 0 && (
<div>
<div className="text-xs text-neutral-500 mb-1">Skills</div>
<div className="flex flex-wrap gap-2">
{skillOptions.map((s) => {
const key = `skill:${s}`;
const on = picked.includes(key);
const disabled = !on && picked.length >= choice.count;
return (
<button
key={key}
type="button"
onClick={() => toggleExpertise(choice, key)}
disabled={disabled}
className={`px-2 py-1 text-xs rounded border capitalize transition ${
on ? "bg-purple-500/30 border-purple-400" : "border-neutral-700 hover:border-neutral-500"
} ${disabled ? "opacity-40 cursor-not-allowed" : ""}`}
>
{s}
</button>
);
})}
</div>
</div>
)}
{choice.toolPool.length > 0 && (
<div>
<div className="text-xs text-neutral-500 mb-1">Tools</div>
<div className="flex flex-wrap gap-2">
{choice.toolPool.map((tool) => {
const key = `tool:${tool}`;
const on = picked.includes(key);
const disabled = !on && picked.length >= choice.count;
return (
<button
key={key}
type="button"
onClick={() => toggleExpertise(choice, key)}
disabled={disabled}
className={`px-2 py-1 text-xs rounded border transition ${
on ? "bg-purple-500/30 border-purple-400" : "border-neutral-700 hover:border-neutral-500"
} ${disabled ? "opacity-40 cursor-not-allowed" : ""}`}
>
{tool}
</button>
);
})}
</div>
</div>
)}
</div>
</div>
);
})}
<div>
<div className="text-sm text-neutral-400 mb-2">
Class starting skills pick {classSkillCount} from the class pool ({chosenFromClass.length}/{classSkillCount}). Non-pool skills shown for reference.
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{SKILLS.map((s) => {
const fromBg = backgroundSkills.includes(s);
const fromFeature = featureChosenSkills.includes(s);
const inClassPool = classSkillOptions.includes(s);
const active = isEffectivelyChosen(s);
const disabled = fromBg || fromFeature || !inClassPool;
const mod = getAbilityModifier(draft.abilities[SKILL_ABILITY[s]]);
return (
<button
key={s}
onClick={() => toggleClassSkill(s)}
disabled={disabled}
className={`text-left p-3 rounded border text-sm transition ${
active
? "bg-amber-500/10 border-amber-500"
: "border-neutral-700 hover:border-neutral-500"
} ${disabled ? "opacity-80 cursor-not-allowed" : ""}`}
>
<div className="flex items-center justify-between">
<span className="font-medium capitalize">{s.replace(/([A-Z])/g, " $1")}</span>
<span className="text-xs text-neutral-500">{SKILL_ABILITY[s].slice(0, 3)}</span>
</div>
<div className="text-xs text-neutral-400 mt-1">
base {mod >= 0 ? `+${mod}` : mod}
{fromBg && <span className="ml-2 text-amber-400">background</span>}
{fromFeature && <span className="ml-2 text-amber-400">feature</span>}
{!fromBg && !fromFeature && inClassPool && <span className="ml-2 text-blue-400">class option</span>}
</div>
</button>
);
})}
</div>
</div>
</div>
);
}