Files
initiative/apps/web/src/components/stat-block.tsx
Lukas 32b69f8df1 Use Readonly props and optional chaining/nullish coalescing
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>
2026-03-14 15:13:39 +01:00

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>
);
}