Files
dndextended/src/app/charactercreator/steps/AbilitiesStep.tsx
T
Alexander Harding a3ea76fdff Vibecode central
2026-07-31 19:50:55 -04:00

479 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import { ABILITIES, Ability, AbilityScores } from "@/types/content";
import { useCreator, AbilityMethod } from "../CreatorContext";
import { useDiceContext } from "@/components/DiceContext";
import { getAbilityModifier } from "@/lib/dndHelpers";
const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8];
const POINT_BUY_COST: Record<number, number> = {
8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9,
};
const POINT_BUY_BUDGET = 27;
function fmtMod(m: number) { return m >= 0 ? `+${m}` : `${m}`; }
export default function AbilitiesStep() {
const { draft, update, species, classes, backgrounds, features, subclasses } = useCreator();
const dice = useDiceContext();
const setMethod = (m: AbilityMethod) => {
if (m === "standardArray") {
update({ abilityMethod: m, abilities: { strength: 8, dexterity: 8, constitution: 8, intelligence: 8, wisdom: 8, charisma: 8 } });
} else if (m === "pointBuy") {
update({ abilityMethod: m, abilities: { strength: 8, dexterity: 8, constitution: 8, intelligence: 8, wisdom: 8, charisma: 8 } });
} else {
update({ abilityMethod: m });
}
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Ability Scores</h2>
<p className="text-sm text-neutral-400 mt-1">Pick a generation method.</p>
</div>
<div className="flex gap-2 flex-wrap">
{(["standardArray", "pointBuy", "rolled", "manual"] as AbilityMethod[]).map((m) => (
<button
key={m}
onClick={() => setMethod(m)}
className={`px-4 py-2 rounded border text-sm ${draft.abilityMethod === m ? "bg-amber-600/20 border-amber-500 text-amber-300" : "border-neutral-700 text-neutral-300"}`}
>
{m === "standardArray" ? "Standard Array" : m === "pointBuy" ? "Point Buy" : m === "rolled" ? "Rolled (4d6 drop 1)" : "Manual"}
</button>
))}
</div>
{draft.abilityMethod === "standardArray" && <StandardArray />}
{draft.abilityMethod === "pointBuy" && <PointBuy />}
{draft.abilityMethod === "rolled" && <Rolled dice={dice} />}
{draft.abilityMethod === "manual" && <Manual />}
<AbilityImprovementSection
species={species}
classes={classes}
backgrounds={backgrounds}
subclasses={subclasses}
features={features}
/>
<ScoreSummary abilities={draft.abilities} />
</div>
);
}
interface AsiSlot {
featureId: string;
featureName: string;
points: number;
maxPerAbility: number;
cap?: number;
}
function AbilityImprovementSection({
species,
classes,
backgrounds,
subclasses,
features,
}: {
species: import("@/types/content").Species[];
classes: import("@/types/content").ClassDef[];
backgrounds: import("@/types/content").Background[];
subclasses: import("@/types/content").Subclass[];
features: Record<string, import("@/types/content").Feature>;
}) {
const { draft, update } = useCreator();
const level = draft.classes[0]?.level ?? 1;
const chosenClass = classes.find((c) => c.id === draft.classes[0]?.classId);
const chosenSpecies = species.find((s) => s.id === draft.speciesId);
const chosenBg = backgrounds.find((b) => b.id === draft.backgroundId);
const chosenSubclassId = draft.classes[0]?.subclassId ??
(chosenClass ? (() => {
for (const lf of chosenClass.levelFeatures ?? []) {
if (lf.level > level) continue;
for (const fid of lf.featureIds) {
const f = features[fid];
if (f?.effects?.some((e) => e.target.kind === "grantSubclassChoice")) {
const pick = draft.featureChoices[fid]?.[0];
if (pick) return pick;
}
}
}
return undefined;
})() : undefined);
const chosenSubclass = subclasses.find((s) => s.id === chosenSubclassId);
const activeFeatureIds = useMemo(() => {
const ids: string[] = [];
ids.push(...(chosenSpecies?.featureIds ?? []));
ids.push(...(chosenBg?.featureIds ?? []));
for (const lf of chosenClass?.levelFeatures ?? []) {
if (lf.level <= level) ids.push(...lf.featureIds);
}
for (const lf of chosenSubclass?.levelFeatures ?? []) {
if (lf.level <= level) ids.push(...lf.featureIds);
}
return ids;
}, [chosenSpecies, chosenBg, chosenClass, chosenSubclass, level]);
const asiSlots: AsiSlot[] = useMemo(() => {
const out: AsiSlot[] = [];
for (const fid of activeFeatureIds) {
const f = features[fid];
if (!f) continue;
for (const eff of f.effects ?? []) {
if (eff.target.kind === "abilityScoreChoice" && (eff.level ?? 1) <= level) {
out.push({
featureId: f.id,
featureName: f.name,
points: eff.target.points,
maxPerAbility: eff.target.maxPerAbility,
cap: eff.target.cap,
});
}
}
}
return out;
}, [activeFeatureIds, features, level]);
if (asiSlots.length === 0) return null;
const applyPatch = (featureId: string, next: string[]) => {
update({ featureChoices: { ...draft.featureChoices, [featureId]: next } });
};
return (
<div className="space-y-4">
{asiSlots.map((slot) => {
const picks = (draft.featureChoices[slot.featureId] ?? []) as Ability[];
const counts: Partial<Record<Ability, number>> = {};
for (const a of picks) counts[a] = (counts[a] ?? 0) + 1;
const remaining = slot.points - picks.length;
// Baseline = current draft ability + fixed bonuses from other active features
// + picks from OTHER asi slots. Excludes this slot's own picks so the delta shown
// for this slot lives on top of that baseline.
const baseline: Record<Ability, number> = { ...draft.abilities };
for (const fid of activeFeatureIds) {
const f = features[fid];
if (!f) continue;
for (const eff of f.effects ?? []) {
if ((eff.level ?? 1) > level) continue;
if (eff.target.kind === "ability") {
const delta =
eff.operation === "decrease" ? -(eff.value ?? 0) : eff.value ?? 0;
if (eff.operation === "set" && eff.value !== undefined) {
baseline[eff.target.ability] = eff.value;
} else {
baseline[eff.target.ability] += delta;
}
} else if (eff.target.kind === "abilityScoreChoice" && f.id !== slot.featureId) {
const otherPicks = (draft.featureChoices[f.id] ?? []) as Ability[];
const otherCounts: Partial<Record<Ability, number>> = {};
for (const a of otherPicks) otherCounts[a] = (otherCounts[a] ?? 0) + 1;
for (const a of ABILITIES) {
baseline[a] += Math.min(otherCounts[a] ?? 0, eff.target.maxPerAbility);
}
}
}
}
const inc = (a: Ability) => {
if (remaining <= 0) return;
if ((counts[a] ?? 0) >= slot.maxPerAbility) return;
if (slot.cap !== undefined && baseline[a] + (counts[a] ?? 0) + 1 > slot.cap) return;
applyPatch(slot.featureId, [...picks, a]);
};
const dec = (a: Ability) => {
const idx = picks.lastIndexOf(a);
if (idx < 0) return;
const next = [...picks];
next.splice(idx, 1);
applyPatch(slot.featureId, next);
};
return (
<div key={slot.featureId} className="p-4 border border-emerald-500/40 bg-emerald-500/5 rounded">
<div className="text-sm font-semibold text-emerald-300 mb-1">
{slot.featureName} distribute {slot.points} points (max {slot.maxPerAbility} per ability{slot.cap ? `, cap ${slot.cap}` : ""})
<span className="ml-2 text-xs text-neutral-500">({picks.length}/{slot.points})</span>
</div>
<div className="grid grid-cols-3 md:grid-cols-6 gap-2 mt-3">
{ABILITIES.map((a) => {
const current = counts[a] ?? 0;
const wouldBe = baseline[a] + current;
const cappedOut = slot.cap !== undefined && wouldBe >= slot.cap;
return (
<div key={a} className="p-2 border border-neutral-700 rounded text-center">
<div className="text-xs uppercase text-neutral-500">{a.slice(0, 3)}</div>
<div className="flex items-center justify-center gap-1 mt-1">
<button type="button" onClick={() => dec(a)} className="w-6 h-6 rounded bg-neutral-800 hover:bg-neutral-700"></button>
<span className="text-lg font-bold w-6 text-emerald-300">+{current}</span>
<button
type="button"
onClick={() => inc(a)}
disabled={cappedOut || remaining <= 0 || current >= slot.maxPerAbility}
className="w-6 h-6 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-30 disabled:cursor-not-allowed"
>+</button>
</div>
<div className={`text-[10px] mt-1 ${cappedOut ? "text-red-400" : "text-neutral-500"}`}>
{wouldBe}{slot.cap !== undefined ? `/${slot.cap}` : ""}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
function ScoreSummary({ abilities }: { abilities: AbilityScores }) {
return (
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
{ABILITIES.map((a) => (
<div key={a} className="p-3 border border-neutral-700 rounded bg-neutral-900 text-center">
<div className="text-xs uppercase text-neutral-500">{a.slice(0, 3)}</div>
<div className="text-2xl font-bold text-neutral-100">{abilities[a]}</div>
<div className="text-sm text-amber-400">{fmtMod(getAbilityModifier(abilities[a]))}</div>
</div>
))}
</div>
);
}
function Manual() {
const { draft, update } = useCreator();
const set = (a: Ability, v: number) =>
update({ abilities: { ...draft.abilities, [a]: v } });
return (
<div className="grid grid-cols-3 md:grid-cols-6 gap-3">
{ABILITIES.map((a) => (
<div key={a}>
<label className="block text-xs uppercase text-neutral-400 mb-1">{a}</label>
<input
type="number"
value={draft.abilities[a]}
onChange={(e) => set(a, Number(e.target.value) || 0)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-2 text-center text-lg"
/>
</div>
))}
</div>
);
}
function StandardArray() {
const { draft, update } = useCreator();
const assignments = ABILITIES.map((a) => draft.abilities[a]);
const remaining = [...STANDARD_ARRAY];
for (const v of assignments) {
const idx = remaining.indexOf(v);
if (idx >= 0) remaining.splice(idx, 1);
}
const setAbility = (a: Ability, value: number) => {
update({ abilities: { ...draft.abilities, [a]: value } });
};
return (
<div className="space-y-3">
<p className="text-sm text-neutral-400">
Pool: <span className="font-mono text-neutral-200">{STANDARD_ARRAY.join(", ")}</span>
</p>
<div className="grid grid-cols-3 md:grid-cols-6 gap-3">
{ABILITIES.map((a) => {
const current = draft.abilities[a];
const options = [8, ...new Set([...STANDARD_ARRAY, current])].sort((x, y) => y - x);
return (
<div key={a}>
<label className="block text-xs uppercase text-neutral-400 mb-1">{a}</label>
<select
value={current}
onChange={(e) => setAbility(a, Number(e.target.value))}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-2 text-center text-lg"
>
{options.map((v) => {
const used = assignments.filter((x) => x === v).length;
const inPool = STANDARD_ARRAY.filter((x) => x === v).length;
const disabled = v !== current && v !== 8 && used >= inPool;
return (
<option key={v} value={v} disabled={disabled}>{v}</option>
);
})}
</select>
</div>
);
})}
</div>
<p className="text-xs text-neutral-500">Unassigned pool: {remaining.length > 0 ? remaining.join(", ") : "empty"}</p>
</div>
);
}
function PointBuy() {
const { draft, update } = useCreator();
const spent = useMemo(
() => ABILITIES.reduce((sum, a) => sum + (POINT_BUY_COST[draft.abilities[a]] ?? 0), 0),
[draft.abilities]
);
const remaining = POINT_BUY_BUDGET - spent;
const adjust = (a: Ability, delta: number) => {
const next = draft.abilities[a] + delta;
if (next < 8 || next > 15) return;
const nextCost = POINT_BUY_COST[next];
const currentCost = POINT_BUY_COST[draft.abilities[a]];
if (POINT_BUY_BUDGET - (spent - currentCost + nextCost) < 0) return;
update({ abilities: { ...draft.abilities, [a]: next } });
};
return (
<div className="space-y-3">
<div className="text-sm">
Points remaining: <span className={`font-bold ${remaining < 0 ? "text-red-400" : "text-amber-400"}`}>{remaining}</span> / {POINT_BUY_BUDGET}
</div>
<div className="grid grid-cols-3 md:grid-cols-6 gap-3">
{ABILITIES.map((a) => (
<div key={a} className="p-3 border border-neutral-700 rounded text-center">
<div className="text-xs uppercase text-neutral-400">{a}</div>
<div className="flex items-center justify-center gap-2 mt-2">
<button onClick={() => adjust(a, -1)} className="w-7 h-7 rounded bg-neutral-800 hover:bg-neutral-700 text-lg"></button>
<span className="text-xl font-bold w-8">{draft.abilities[a]}</span>
<button onClick={() => adjust(a, 1)} className="w-7 h-7 rounded bg-neutral-800 hover:bg-neutral-700 text-lg">+</button>
</div>
<div className="text-xs text-neutral-500 mt-1">cost {POINT_BUY_COST[draft.abilities[a]] ?? "—"}</div>
</div>
))}
</div>
</div>
);
}
function Rolled({ dice }: { dice: ReturnType<typeof useDiceContext> }) {
const { draft, update } = useCreator();
const [pool, setPool] = useState<number[]>([]);
const [assignments, setAssignments] = useState<(Ability | null)[]>(
() => Array.from({ length: 6 }, () => null)
);
const [rolling, setRolling] = useState(false);
const rollOne = async (): Promise<number> => {
const roller = dice.rollerRef.current;
if (roller) {
try {
const outcome = await roller.roll({ 6: 4 }, "Ability roll (4d6 drop lowest)");
const rolls = outcome.roll.map((r) => r.value).sort((a, b) => b - a);
return rolls[0] + rolls[1] + rolls[2];
} catch {
/* fall through */
}
}
const rolls = Array.from({ length: 4 }, () => 1 + Math.floor(Math.random() * 6)).sort((a, b) => b - a);
return rolls[0] + rolls[1] + rolls[2];
};
const rollNext = async () => {
if (pool.length >= 6) return;
setRolling(true);
const v = await rollOne();
setPool([...pool, v]);
setRolling(false);
};
const setSlot = (i: number, v: number) => {
const next = [...pool];
next[i] = v;
setPool(next);
};
const setAssignment = (i: number, a: Ability | null) => {
const next = [...assignments];
next[i] = a;
setAssignments(next);
};
const applyAssignments = () => {
const nextAbilities = { ...draft.abilities };
for (let i = 0; i < 6; i++) {
const a = assignments[i];
const v = pool[i];
if (a && typeof v === "number" && v > 0) nextAbilities[a] = v;
}
update({ abilities: nextAbilities });
};
const hasSomethingToApply = assignments.some((a, i) => a && (pool[i] ?? 0) > 0);
return (
<div className="space-y-4">
<div className="flex gap-2 flex-wrap">
<button
onClick={rollNext}
disabled={rolling || pool.length >= 6}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{rolling ? "Rolling..." : pool.length >= 6 ? "Pool full" : `Roll next (${pool.length}/6)`}
</button>
<button
onClick={() => setPool([])}
className="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200"
>
Clear
</button>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">
Pool (physical-dice players: enter values manually). Pick an ability under each slot to assign it.
</label>
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex flex-col gap-1">
<input
type="number"
min={3}
max={18}
value={pool[i] ?? ""}
onChange={(e) => setSlot(i, Number(e.target.value) || 0)}
placeholder="—"
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-2 text-center text-lg"
/>
<select
value={assignments[i] ?? ""}
onChange={(e) => setAssignment(i, (e.target.value || null) as Ability | null)}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm capitalize"
>
<option value="">---</option>
{ABILITIES.map((a) => {
const takenByOther = assignments.some((x, k) => k !== i && x === a);
return (
<option key={a} value={a} disabled={takenByOther}>{a}</option>
);
})}
</select>
</div>
))}
</div>
</div>
<div>
<button
type="button"
onClick={applyAssignments}
disabled={!hasSomethingToApply}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
Apply changes
</button>
</div>
</div>
);
}