479 lines
18 KiB
TypeScript
479 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|