Mark component props as Readonly<> across 15 component files and simplify edit-player-character field access with optional chaining and nullish coalescing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
6.9 KiB
TypeScript
260 lines
6.9 KiB
TypeScript
import {
|
|
type Creature,
|
|
calculateInitiative,
|
|
formatInitiativeModifier,
|
|
} from "@initiative/domain";
|
|
|
|
interface StatBlockProps {
|
|
creature: Creature;
|
|
}
|
|
|
|
function abilityMod(score: number): string {
|
|
const mod = Math.floor((score - 10) / 2);
|
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
|
}
|
|
|
|
function PropertyLine({
|
|
label,
|
|
value,
|
|
}: Readonly<{
|
|
label: string;
|
|
value: string | undefined;
|
|
}>) {
|
|
if (!value) return null;
|
|
return (
|
|
<div className="text-sm">
|
|
<span className="font-semibold">{label}</span> {value}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionDivider() {
|
|
return (
|
|
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
|
|
);
|
|
}
|
|
|
|
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|
const abilities = [
|
|
{ label: "STR", score: creature.abilities.str },
|
|
{ label: "DEX", score: creature.abilities.dex },
|
|
{ label: "CON", score: creature.abilities.con },
|
|
{ label: "INT", score: creature.abilities.int },
|
|
{ label: "WIS", score: creature.abilities.wis },
|
|
{ label: "CHA", score: creature.abilities.cha },
|
|
];
|
|
|
|
const initiative = calculateInitiative({
|
|
dexScore: creature.abilities.dex,
|
|
cr: creature.cr,
|
|
initiativeProficiency: creature.initiativeProficiency,
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-1 text-foreground">
|
|
{/* Header */}
|
|
<div>
|
|
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2>
|
|
<p className="text-muted-foreground text-sm italic">
|
|
{creature.size} {creature.type}, {creature.alignment}
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{creature.sourceDisplayName}
|
|
</p>
|
|
</div>
|
|
|
|
<SectionDivider />
|
|
|
|
{/* Stats bar */}
|
|
<div className="space-y-0.5 text-sm">
|
|
<div>
|
|
<span className="font-semibold">Armor Class</span> {creature.ac}
|
|
{!!creature.acSource && (
|
|
<span className="text-muted-foreground">
|
|
{" "}
|
|
({creature.acSource})
|
|
</span>
|
|
)}
|
|
<span className="ml-3">
|
|
<span className="font-semibold">Initiative</span>{" "}
|
|
{formatInitiativeModifier(initiative.modifier)} (
|
|
{initiative.passive})
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">Hit Points</span>{" "}
|
|
{creature.hp.average}{" "}
|
|
<span className="text-muted-foreground">({creature.hp.formula})</span>
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">Speed</span> {creature.speed}
|
|
</div>
|
|
</div>
|
|
|
|
<SectionDivider />
|
|
|
|
{/* Ability scores */}
|
|
<div className="grid grid-cols-6 gap-1 text-center text-sm">
|
|
{abilities.map(({ label, score }) => (
|
|
<div key={label}>
|
|
<div className="font-semibold">{label}</div>
|
|
<div>
|
|
{score}{" "}
|
|
<span className="text-muted-foreground">
|
|
({abilityMod(score)})
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<SectionDivider />
|
|
|
|
{/* Properties */}
|
|
<div className="space-y-0.5">
|
|
<PropertyLine label="Saving Throws" value={creature.savingThrows} />
|
|
<PropertyLine label="Skills" value={creature.skills} />
|
|
<PropertyLine
|
|
label="Damage Vulnerabilities"
|
|
value={creature.vulnerable}
|
|
/>
|
|
<PropertyLine label="Damage Resistances" value={creature.resist} />
|
|
<PropertyLine label="Damage Immunities" value={creature.immune} />
|
|
<PropertyLine
|
|
label="Condition Immunities"
|
|
value={creature.conditionImmune}
|
|
/>
|
|
<PropertyLine label="Senses" value={creature.senses} />
|
|
<PropertyLine label="Languages" value={creature.languages} />
|
|
<div className="text-sm">
|
|
<span className="font-semibold">Challenge</span> {creature.cr}{" "}
|
|
<span className="text-muted-foreground">
|
|
(Proficiency Bonus +{creature.proficiencyBonus})
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Traits */}
|
|
{creature.traits && creature.traits.length > 0 && (
|
|
<>
|
|
<SectionDivider />
|
|
<div className="space-y-2">
|
|
{creature.traits.map((t) => (
|
|
<div key={t.name} className="text-sm">
|
|
<span className="font-semibold italic">{t.name}.</span> {t.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Spellcasting */}
|
|
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
|
<>
|
|
<SectionDivider />
|
|
{creature.spellcasting.map((sc) => (
|
|
<div key={sc.name} className="space-y-1 text-sm">
|
|
<div>
|
|
<span className="font-semibold italic">{sc.name}.</span>{" "}
|
|
{sc.headerText}
|
|
</div>
|
|
{sc.atWill && sc.atWill.length > 0 && (
|
|
<div className="pl-2">
|
|
<span className="font-semibold">At Will:</span>{" "}
|
|
{sc.atWill.join(", ")}
|
|
</div>
|
|
)}
|
|
{sc.daily?.map((d) => (
|
|
<div key={`${d.uses}${d.each ? "e" : ""}`} className="pl-2">
|
|
<span className="font-semibold">
|
|
{d.uses}/day
|
|
{d.each ? " each" : ""}:
|
|
</span>{" "}
|
|
{d.spells.join(", ")}
|
|
</div>
|
|
))}
|
|
{sc.restLong?.map((d) => (
|
|
<div
|
|
key={`rest-${d.uses}${d.each ? "e" : ""}`}
|
|
className="pl-2"
|
|
>
|
|
<span className="font-semibold">
|
|
{d.uses}/long rest
|
|
{d.each ? " each" : ""}:
|
|
</span>{" "}
|
|
{d.spells.join(", ")}
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
{creature.actions && creature.actions.length > 0 && (
|
|
<>
|
|
<SectionDivider />
|
|
<h3 className="font-bold text-amber-400 text-base">Actions</h3>
|
|
<div className="space-y-2">
|
|
{creature.actions.map((a) => (
|
|
<div key={a.name} className="text-sm">
|
|
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Bonus Actions */}
|
|
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
|
<>
|
|
<SectionDivider />
|
|
<h3 className="font-bold text-amber-400 text-base">Bonus Actions</h3>
|
|
<div className="space-y-2">
|
|
{creature.bonusActions.map((a) => (
|
|
<div key={a.name} className="text-sm">
|
|
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Reactions */}
|
|
{creature.reactions && creature.reactions.length > 0 && (
|
|
<>
|
|
<SectionDivider />
|
|
<h3 className="font-bold text-amber-400 text-base">Reactions</h3>
|
|
<div className="space-y-2">
|
|
{creature.reactions.map((a) => (
|
|
<div key={a.name} className="text-sm">
|
|
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Legendary Actions */}
|
|
{!!creature.legendaryActions && (
|
|
<>
|
|
<SectionDivider />
|
|
<h3 className="font-bold text-amber-400 text-base">
|
|
Legendary Actions
|
|
</h3>
|
|
<p className="text-muted-foreground text-sm italic">
|
|
{creature.legendaryActions.preamble}
|
|
</p>
|
|
<div className="space-y-2">
|
|
{creature.legendaryActions.entries.map((a) => (
|
|
<div key={a.name} className="text-sm">
|
|
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|