Files
initiative/apps/web/src/components/stat-block.tsx
Lukas 36768d3aa1 Upgrade Biome to 2.4.7 and enable 54 additional lint rules
Add rules covering bug prevention (noLeakedRender, noFloatingPromises,
noImportCycles, noReactForwardRef), security (noScriptUrl, noAlert),
performance (noAwaitInLoops, useTopLevelRegex), and code style
(noNestedTernary, useGlobalThis, useNullishCoalescing, useSortedClasses,
plus ~40 more). Fix all violations: extract top-level regex constants,
guard React && renders with boolean coercion, refactor nested ternaries,
replace window with globalThis, sort Tailwind classes, and introduce
expectDomainError test helper to eliminate conditional expects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:25:09 +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,
}: {
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 }: 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>
);
}