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

601 lines
25 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 { 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 (
<input
type="text"
value={buf}
onChange={(e) => 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<EffectTarget["kind"], string> = {
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<Effect>) => {
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 (
<div className="space-y-3">
{effects.length === 0 && (
<p className="text-sm text-neutral-500 italic">No effects. Click below to add one.</p>
)}
{effects.map((eff, i) => {
const kind = eff.target.kind;
return (
<div key={i} className="flex flex-wrap items-center gap-2 p-3 border border-neutral-700 rounded bg-neutral-950/50">
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={kind}
onChange={(e) => updateTarget(i, defaultTargetFor(e.target.value as EffectTarget["kind"]))}
>
{TARGET_KINDS.map((k) => (
<option key={k} value={k}>{KIND_LABELS[k]}</option>
))}
</select>
{eff.target.kind === "ability" && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.target.ability}
onChange={(e) => updateTarget(i, { kind: "ability", ability: e.target.value as Ability })}
>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
)}
{eff.target.kind === "abilityScoreChoice" && (() => {
const t = eff.target;
const setPatch = (patch: Partial<typeof t>) =>
updateTarget(i, { ...t, ...patch } as EffectTarget);
return (
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">distribute</span>
<input
type="number"
min={1}
value={t.points}
onChange={(e) => 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"
/>
<span className="text-xs text-neutral-500">points, max</span>
<input
type="number"
min={1}
value={t.maxPerAbility}
onChange={(e) => 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"
/>
<span className="text-xs text-neutral-500">per ability, cap</span>
<input
type="number"
min={1}
placeholder="none"
value={t.cap ?? ""}
onChange={(e) =>
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"
/>
</div>
);
})()}
{eff.target.kind === "savingThrowProficiency" && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.target.ability}
onChange={(e) => updateTarget(i, { kind: "savingThrowProficiency", ability: e.target.value as Ability })}
>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
)}
{(eff.target.kind === "skillProficiency" || eff.target.kind === "skillExpertise") && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.target.skill}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "skillProficiency" | "skillExpertise", skill: e.target.value as Skill })}
>
{SKILLS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)}
{eff.target.kind === "toolExpertise" && (
<input
type="text"
placeholder="tool name (e.g. thieves' tools)"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-56"
value={eff.target.tool}
onChange={(e) => updateTarget(i, { kind: "toolExpertise", tool: e.target.value })}
/>
)}
{eff.target.kind === "expertiseChoice" && (() => {
const t = eff.target;
const setPatch = (patch: Partial<typeof t>) =>
updateTarget(i, { ...t, ...patch } as EffectTarget);
const skillPoolMode = t.skillPool === "characterSkills" ? "characterSkills" : "specific";
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">choose</span>
<input
type="number"
min={1}
value={t.count}
onChange={(e) => 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"
/>
<span className="text-xs text-neutral-500">total from:</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-neutral-400">Skill pool:</span>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={skillPoolMode}
onChange={(e) =>
setPatch({
skillPool: e.target.value === "characterSkills" ? "characterSkills" : [],
})
}
>
<option value="characterSkills">Any skill character is proficient in</option>
<option value="specific">Specific skills</option>
</select>
</div>
{Array.isArray(t.skillPool) && (
<div className="flex flex-wrap gap-1">
{SKILLS.map((s) => {
const arr = t.skillPool as Skill[];
const on = arr.includes(s);
return (
<label
key={s}
className={`px-2 py-0.5 text-xs rounded border cursor-pointer ${on ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}
>
<input
type="checkbox"
className="hidden"
checked={on}
onChange={() =>
setPatch({
skillPool: on ? arr.filter((x) => x !== s) : [...arr, s],
})
}
/>
{s}
</label>
);
})}
</div>
)}
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-400">Also eligible tools (comma):</span>
<CommaListInput
value={t.toolPool}
onCommit={(next) => setPatch({ toolPool: next })}
placeholder="thieves' tools"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
/>
</div>
</div>
);
})()}
{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 (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">choose</span>
<input
type="number"
min={1}
max={t.from.length || 18}
value={t.count}
onChange={(e) => 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"
/>
<span className="text-xs text-neutral-500">of {t.from.length} option(s)</span>
</div>
<div className="flex flex-wrap gap-1">
{SKILLS.map((s) => {
const on = t.from.includes(s);
return (
<label
key={s}
className={`px-2 py-0.5 text-xs rounded border cursor-pointer ${on ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}
>
<input type="checkbox" className="hidden" checked={on} onChange={() => toggleFrom(s)} />
{s}
</label>
);
})}
</div>
</div>
);
})()}
{(eff.target.kind === "armorProficiency" ||
eff.target.kind === "weaponProficiency" ||
eff.target.kind === "toolProficiency" ||
eff.target.kind === "language") && (
<input
type="text"
placeholder="value"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-40"
value={eff.target.value}
onChange={(e) => 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") && (
<input
type="text"
placeholder="damage type"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-32"
value={eff.target.damageType}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "resistance" | "immunity" | "vulnerability", damageType: e.target.value })}
/>
)}
{eff.target.kind === "grantSpell" && (
<input
type="text"
placeholder="spell id"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-40"
value={eff.target.spellId}
onChange={(e) => updateTarget(i, { kind: "grantSpell", spellId: e.target.value })}
/>
)}
{eff.target.kind === "grantSpellcasting" && (() => {
const t = eff.target;
const setPatch = (patch: Partial<typeof t>) =>
updateTarget(i, { ...t, ...patch } as EffectTarget);
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">Ability:</span>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={t.ability}
onChange={(e) => setPatch({ ability: e.target.value as Ability })}
>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<label className="text-xs text-neutral-500 flex items-center gap-1">
<input
type="checkbox"
checked={t.ritual === true}
onChange={(e) => setPatch({ ritual: e.target.checked })}
/>
Ritual casting
</label>
</div>
<SpellProgressionTable
progression={t.progression}
onChange={(rows) => setPatch({ progression: rows })}
/>
</div>
);
})()}
{eff.target.kind === "grantFeat" && (
<input
type="text"
placeholder="feat id"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-40"
value={eff.target.featId}
onChange={(e) => updateTarget(i, { kind: "grantFeat", featId: e.target.value })}
/>
)}
{eff.target.kind === "grantSubclassChoice" && (
<input
type="text"
placeholder="label (e.g. Roguish Archetype)"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
value={eff.target.label ?? ""}
onChange={(e) => updateTarget(i, { kind: "grantSubclassChoice", label: e.target.value })}
/>
)}
{eff.target.kind === "extraAttack" && (
<input
type="number"
min={1}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-20"
value={eff.target.count}
onChange={(e) => 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 (
<>
<span className="text-xs text-neutral-500">on</span>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={scope.on}
onChange={(e) => setScope(defaultScope(e.target.value as AdvantageScope["on"]))}
>
{ADV_SCOPES.map((s) => <option key={s} value={s}>{SCOPE_LABELS[s]}</option>)}
</select>
{scope.on === "skill" && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={scope.skill}
onChange={(e) => setScope({ on: "skill", skill: e.target.value as Skill })}
>
{SKILLS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)}
{(scope.on === "save" || scope.on === "check") && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={scope.ability ?? ""}
onChange={(e) => {
const val = e.target.value;
setScope({ on: scope.on, ability: val ? (val as Ability) : undefined } as AdvantageScope);
}}
>
<option value="">all</option>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
)}
<input
type="text"
placeholder="condition (optional, e.g. 'vs poison')"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1 min-w-[10rem]"
value={cond}
onChange={(e) => setCond(e.target.value)}
/>
</>
);
})()}
{(eff.target.kind === "action" || eff.target.kind === "bonusAction" || eff.target.kind === "reaction") && (
<input
type="text"
placeholder="note (e.g. 'Dash, Disengage, or Hide')"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
value={eff.target.note}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "action" | "bonusAction" | "reaction", note: e.target.value })}
/>
)}
{eff.target.kind === "custom" && (
<input
type="text"
placeholder="note"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
value={eff.target.note}
onChange={(e) => updateTarget(i, { kind: "custom", note: e.target.value })}
/>
)}
{needsValue(kind) && (
<>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.operation}
onChange={(e) => update(i, { operation: e.target.value as Effect["operation"] })}
>
<option value="increase">+</option>
<option value="decrease"></option>
<option value="set">=</option>
</select>
<input
type="number"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-20"
value={eff.value ?? 0}
onChange={(e) => update(i, { value: Number(e.target.value) })}
/>
</>
)}
<div className="ml-auto flex items-center gap-1">
<label className="text-[10px] text-neutral-500 uppercase">Lv</label>
<input
type="number"
min={1}
max={20}
placeholder="1"
value={eff.level ?? ""}
onChange={(e) => 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"
/>
<button
onClick={() => remove(i)}
className="text-red-400 hover:text-red-300 text-sm px-2"
type="button"
>
Remove
</button>
</div>
</div>
);
})}
<button
onClick={add}
type="button"
className="text-sm text-amber-400 border border-amber-400/40 hover:bg-amber-400/10 rounded px-3 py-1"
>
+ Add effect
</button>
</div>
);
}