"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 = { 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 (

Ability Scores

Pick a generation method.

{(["standardArray", "pointBuy", "rolled", "manual"] as AbilityMethod[]).map((m) => ( ))}
{draft.abilityMethod === "standardArray" && } {draft.abilityMethod === "pointBuy" && } {draft.abilityMethod === "rolled" && } {draft.abilityMethod === "manual" && }
); } 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; }) { 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 (
{asiSlots.map((slot) => { const picks = (draft.featureChoices[slot.featureId] ?? []) as Ability[]; const counts: Partial> = {}; 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 = { ...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> = {}; 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 (
{slot.featureName} — distribute {slot.points} points (max {slot.maxPerAbility} per ability{slot.cap ? `, cap ${slot.cap}` : ""}) ({picks.length}/{slot.points})
{ABILITIES.map((a) => { const current = counts[a] ?? 0; const wouldBe = baseline[a] + current; const cappedOut = slot.cap !== undefined && wouldBe >= slot.cap; return (
{a.slice(0, 3)}
+{current}
{wouldBe}{slot.cap !== undefined ? `/${slot.cap}` : ""}
); })}
); })}
); } function ScoreSummary({ abilities }: { abilities: AbilityScores }) { return (
{ABILITIES.map((a) => (
{a.slice(0, 3)}
{abilities[a]}
{fmtMod(getAbilityModifier(abilities[a]))}
))}
); } function Manual() { const { draft, update } = useCreator(); const set = (a: Ability, v: number) => update({ abilities: { ...draft.abilities, [a]: v } }); return (
{ABILITIES.map((a) => (
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" />
))}
); } 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 (

Pool: {STANDARD_ARRAY.join(", ")}

{ABILITIES.map((a) => { const current = draft.abilities[a]; const options = [8, ...new Set([...STANDARD_ARRAY, current])].sort((x, y) => y - x); return (
); })}

Unassigned pool: {remaining.length > 0 ? remaining.join(", ") : "empty"}

); } 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 (
Points remaining: {remaining} / {POINT_BUY_BUDGET}
{ABILITIES.map((a) => (
{a}
{draft.abilities[a]}
cost {POINT_BUY_COST[draft.abilities[a]] ?? "—"}
))}
); } function Rolled({ dice }: { dice: ReturnType }) { const { draft, update } = useCreator(); const [pool, setPool] = useState([]); const [assignments, setAssignments] = useState<(Ability | null)[]>( () => Array.from({ length: 6 }, () => null) ); const [rolling, setRolling] = useState(false); const rollOne = async (): Promise => { 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 (
{Array.from({ length: 6 }).map((_, i) => (
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" />
))}
); }