"use client"; import { useEffect, useState } from "react"; import { ABILITIES, Ability, Skill, SKILL_ABILITY } from "@/types/content"; import type { AdvantageScope, Effect, EffectTarget } from "@/types/core"; import SpellProgressionTable from "./SpellProgressionTable"; /** * Comma-list input with a local string buffer so typing spaces and multi-word items * (like "thieves' tools") doesn't get eaten while typing. */ function CommaListInput({ value, onCommit, placeholder, className, }: { value: string[]; onCommit: (next: string[]) => void; placeholder?: string; className?: string; }) { const [buf, setBuf] = useState(value.join(", ")); useEffect(() => { setBuf(value.join(", ")); // eslint-disable-next-line react-hooks/exhaustive-deps }, [value.join("\u0001")]); const commit = () => { const parsed = buf.split(",").map((s) => s.trim()).filter(Boolean); onCommit(parsed); }; return ( setBuf(e.target.value)} onBlur={commit} placeholder={placeholder} className={className} /> ); } const SKILLS = Object.keys(SKILL_ABILITY) as Skill[]; const TARGET_KINDS: EffectTarget["kind"][] = [ "ability", "abilityScoreChoice", "speed", "hpMax", "armorClass", "initiative", "savingThrowProficiency", "skillProficiency", "skillProficiencyChoice", "skillExpertise", "toolExpertise", "expertiseChoice", "armorProficiency", "weaponProficiency", "toolProficiency", "language", "resistance", "immunity", "vulnerability", "grantSpell", "grantSpellcasting", "grantFeat", "extraAttack", "grantSubclassChoice", "advantage", "disadvantage", "action", "bonusAction", "reaction", "custom", ]; const KIND_LABELS: Record = { ability: "Ability score", abilityScoreChoice: "Ability score improvement (choose)", speed: "Speed", hpMax: "HP Max", armorClass: "Armor class", initiative: "Initiative", savingThrowProficiency: "Saving throw prof.", skillProficiency: "Skill proficiency", skillProficiencyChoice: "Skill proficiency (choose N)", skillExpertise: "Skill expertise", toolExpertise: "Tool expertise", expertiseChoice: "Expertise (choose N)", armorProficiency: "Armor proficiency", weaponProficiency: "Weapon proficiency", toolProficiency: "Tool proficiency", language: "Language", resistance: "Damage resistance", immunity: "Damage immunity", vulnerability: "Damage vulnerability", grantSpell: "Grant spell", grantSpellcasting: "Grant spellcasting", grantFeat: "Grant feat", extraAttack: "Extra attack", grantSubclassChoice: "Grant subclass choice", advantage: "Advantage", disadvantage: "Disadvantage", action: "Action", bonusAction: "Bonus Action", reaction: "Reaction", custom: "Custom note", }; const ADV_SCOPES = ["attack", "save", "skill", "check"] as const; const SCOPE_LABELS: Record<(typeof ADV_SCOPES)[number], string> = { attack: "attack rolls", save: "saving throws", skill: "skill check", check: "ability checks", }; function defaultTargetFor(kind: EffectTarget["kind"]): EffectTarget { switch (kind) { case "ability": return { kind, ability: "strength" }; case "abilityScoreChoice": return { kind, points: 2, maxPerAbility: 2, cap: 20 }; case "speed": case "hpMax": case "armorClass": case "initiative": return { kind }; case "savingThrowProficiency": return { kind, ability: "strength" }; case "skillProficiency": case "skillExpertise": return { kind, skill: "athletics" }; case "skillProficiencyChoice": return { kind, count: 2, from: [] }; case "toolExpertise": return { kind, tool: "" }; case "expertiseChoice": return { kind, count: 2, skillPool: "characterSkills", toolPool: [] }; case "armorProficiency": case "weaponProficiency": case "toolProficiency": case "language": return { kind, value: "" }; case "resistance": case "immunity": case "vulnerability": return { kind, damageType: "fire" }; case "grantSpell": return { kind, spellId: "" }; case "grantSpellcasting": return { kind, ability: "intelligence", progression: [{ level: 1, cantripsKnown: 0, spellsKnown: 0, slots: [] }], }; case "grantFeat": return { kind, featId: "" }; case "extraAttack": return { kind, count: 1 }; case "grantSubclassChoice": return { kind, label: "" }; case "advantage": case "disadvantage": return { kind, scope: { on: "attack" }, condition: "" }; case "action": case "bonusAction": case "reaction": return { kind, note: "" }; case "custom": return { kind, note: "" }; } } function defaultScope(on: AdvantageScope["on"]): AdvantageScope { switch (on) { case "attack": return { on }; case "save": return { on, ability: undefined }; case "skill": return { on, skill: "athletics" }; case "check": return { on, ability: undefined }; } } interface Props { effects: Effect[]; onChange: (next: Effect[]) => void; } export default function EffectEditor({ effects, onChange }: Props) { const update = (idx: number, patch: Partial) => { onChange(effects.map((e, i) => (i === idx ? { ...e, ...patch } : e))); }; const updateTarget = (idx: number, target: EffectTarget) => update(idx, { target }); const remove = (idx: number) => onChange(effects.filter((_, i) => i !== idx)); const add = () => onChange([...effects, { target: { kind: "ability", ability: "strength" }, operation: "increase", value: 1 }]); const needsValue = (kind: EffectTarget["kind"]) => ["ability", "speed", "hpMax", "armorClass", "initiative"].includes(kind); return (
{effects.length === 0 && (

No effects. Click below to add one.

)} {effects.map((eff, i) => { const kind = eff.target.kind; return (
{eff.target.kind === "ability" && ( )} {eff.target.kind === "abilityScoreChoice" && (() => { const t = eff.target; const setPatch = (patch: Partial) => updateTarget(i, { ...t, ...patch } as EffectTarget); return (
distribute setPatch({ points: Math.max(1, Number(e.target.value) || 1) })} className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16" /> points, max setPatch({ maxPerAbility: Math.max(1, Number(e.target.value) || 1) })} className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16" /> per ability, cap setPatch({ cap: e.target.value ? Math.max(1, Number(e.target.value)) : undefined }) } className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16" />
); })()} {eff.target.kind === "savingThrowProficiency" && ( )} {(eff.target.kind === "skillProficiency" || eff.target.kind === "skillExpertise") && ( )} {eff.target.kind === "toolExpertise" && ( updateTarget(i, { kind: "toolExpertise", tool: e.target.value })} /> )} {eff.target.kind === "expertiseChoice" && (() => { const t = eff.target; const setPatch = (patch: Partial) => updateTarget(i, { ...t, ...patch } as EffectTarget); const skillPoolMode = t.skillPool === "characterSkills" ? "characterSkills" : "specific"; return (
choose setPatch({ count: Math.max(1, Number(e.target.value) || 1) })} className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16" /> total from:
Skill pool:
{Array.isArray(t.skillPool) && (
{SKILLS.map((s) => { const arr = t.skillPool as Skill[]; const on = arr.includes(s); return ( ); })}
)}
Also eligible tools (comma): setPatch({ toolPool: next })} placeholder="thieves' tools" className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1" />
); })()} {eff.target.kind === "skillProficiencyChoice" && (() => { const t = eff.target; const setCount = (n: number) => updateTarget(i, { kind: "skillProficiencyChoice", count: n, from: t.from }); const toggleFrom = (s: Skill) => { const next = t.from.includes(s) ? t.from.filter((x) => x !== s) : [...t.from, s]; updateTarget(i, { kind: "skillProficiencyChoice", count: t.count, from: next }); }; return (
choose setCount(Math.max(1, Number(e.target.value) || 1))} className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16" /> of {t.from.length} option(s)
{SKILLS.map((s) => { const on = t.from.includes(s); return ( ); })}
); })()} {(eff.target.kind === "armorProficiency" || eff.target.kind === "weaponProficiency" || eff.target.kind === "toolProficiency" || eff.target.kind === "language") && ( updateTarget(i, { kind: eff.target.kind as "armorProficiency" | "weaponProficiency" | "toolProficiency" | "language", value: e.target.value })} /> )} {(eff.target.kind === "resistance" || eff.target.kind === "immunity" || eff.target.kind === "vulnerability") && ( updateTarget(i, { kind: eff.target.kind as "resistance" | "immunity" | "vulnerability", damageType: e.target.value })} /> )} {eff.target.kind === "grantSpell" && ( updateTarget(i, { kind: "grantSpell", spellId: e.target.value })} /> )} {eff.target.kind === "grantSpellcasting" && (() => { const t = eff.target; const setPatch = (patch: Partial) => updateTarget(i, { ...t, ...patch } as EffectTarget); return (
Ability:
setPatch({ progression: rows })} />
); })()} {eff.target.kind === "grantFeat" && ( updateTarget(i, { kind: "grantFeat", featId: e.target.value })} /> )} {eff.target.kind === "grantSubclassChoice" && ( updateTarget(i, { kind: "grantSubclassChoice", label: e.target.value })} /> )} {eff.target.kind === "extraAttack" && ( updateTarget(i, { kind: "extraAttack", count: Number(e.target.value) || 1 })} /> )} {(eff.target.kind === "advantage" || eff.target.kind === "disadvantage") && (() => { const kindLocal = eff.target.kind; const scope = eff.target.scope; const cond = eff.target.condition ?? ""; const setScope = (next: AdvantageScope) => updateTarget(i, { kind: kindLocal, scope: next, condition: cond }); const setCond = (c: string) => updateTarget(i, { kind: kindLocal, scope, condition: c }); return ( <> on {scope.on === "skill" && ( )} {(scope.on === "save" || scope.on === "check") && ( )} setCond(e.target.value)} /> ); })()} {(eff.target.kind === "action" || eff.target.kind === "bonusAction" || eff.target.kind === "reaction") && ( updateTarget(i, { kind: eff.target.kind as "action" | "bonusAction" | "reaction", note: e.target.value })} /> )} {eff.target.kind === "custom" && ( updateTarget(i, { kind: "custom", note: e.target.value })} /> )} {needsValue(kind) && ( <> update(i, { value: Number(e.target.value) })} /> )}
update(i, { level: e.target.value ? Number(e.target.value) : undefined })} className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-14 text-center" title="Minimum level for this effect to apply" />
); })}
); }