Vibecode central
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user