Vibecode central
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user