V1.30.0: class features that change a number are worked out on the sheet

- Unarmored Defense (barbarian, monk), Draconic Resilience, Fast Movement,
  Unarmored Movement, Aura of Protection, Diamond Soul, Slippery Mind and
  Jack of All Trades are counted, by level in the class that gives them
- Armor and shields are items marked with what kind they are, so the sheet
  knows when a character is unarmored or in heavy armor; the library's and
  the seeds' are marked
- The sheet lists the class features in force; a speed the DM set is final
- A cantrip can be cast with every spell slot spent (tested end to end)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 11:13:09 +02:00
co-authored by Claude Opus 5.5
parent 9d0a4a2252
commit 16b5ac05ca
30 changed files with 533 additions and 63 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.29.0",
"version": "1.30.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tansta
import { apiClient } from './client';
import type {
Ability,
ArmorType,
ActionPatch,
ActionSource,
BuildOptions,
@@ -652,6 +653,7 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
saveAbility?: Ability | null;
halfOnSave?: boolean;
locationId?: string | null;
armorType?: ArmorType | null; // an item: what kind of armor it is, if any
}) =>
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
onSuccess: () => {
+13
View File
@@ -384,6 +384,7 @@ export interface CharacterSheet {
spellSlots: SpellSlots | null; // null for a character with no spell slots
resources: ClassResource[]; // the class counters, with how many of each are left
gear: ActiveGear;
features: FeatureInForce[]; // class features already counted in the numbers above
}
export interface Weapon {
@@ -461,6 +462,18 @@ export interface Item {
isStartingOption: boolean;
effects: GearEffect[]; // armor is an item with an AC effect
effectNote: string | null;
armorType: ArmorType | null; // set on armor and shields: what kind it is
}
// What kind of armor an item is. The sheet goes by what is equipped for the
// class features that depend on it (Unarmored Defense, a monk's speed...).
export type ArmorType = 'LIGHT' | 'MEDIUM' | 'HEAVY' | 'SHIELD';
// A class feature counted in the sheet's numbers, in a line.
export interface FeatureInForce {
name: string;
source: string; // the class or subclass that gives it
text: string;
}
export interface InventoryItem {
+1 -1
View File
@@ -16,7 +16,7 @@ const STAT_HELP: Record<string, string> = {
SKILL_BONUS: 'Skill Bonus — added when you roll to use a skill. The governing ability modifier, plus your proficiency bonus if you are proficient (doubled if you have expertise).',
EXPERTISE: 'Expertise — doubles your proficiency bonus for this skill, on top of being proficient.',
ABILITY_MODIFIER: 'Ability Modifier — the number added to rolls that use this ability. floor((score − 10) / 2).',
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item.',
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item. Unarmored Defense (barbarian, monk) and a draconic sorcerer’s scales are worked out for you: do not add them here.',
SPEED: 'Speed — how far you can move on your turn, in feet. Set by your species, unless overridden.',
SPELL_SAVE_DC: 'Spell Save DC — the difficulty a target must beat to resist your spell. 8 + proficiency bonus + your spellcasting ability modifier.',
SPELL_ATTACK_BONUS: 'Spell Attack Bonus — added when you make a spell attack roll. Proficiency bonus + your spellcasting ability modifier.',
@@ -9,7 +9,7 @@ import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } f
import { abilityLine, rateLabel, type EntryKind, type EntryRef } from '../../lib/campaignEntries';
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
import { landingValue } from '../../lib/campaignEntries';
import type { GearEffect, Item, Monster, Npc, Spell, Weapon } from '../../api/types';
import type { GearEffect, Item, Monster, Npc, Spell, Weapon, ArmorType } from '../../api/types';
const KIND_NOUNS: Record<EntryKind, string> = { weapon: 'weapon', item: 'item', spell: 'spell', monster: 'monster', npc: 'NPC' };
const ATTACK_ABILITY_LABELS: Record<Weapon['attackAbility'], string> = { STR: 'STR', DEX: 'DEX', FINESSE: 'Finesse (the better of STR and DEX)' };
@@ -90,6 +90,27 @@ function GearDetail({ campaignId, pool, entry, added, onClose }: { added: ReactN
Starting option: a player may pick it while building a character
</label>
{!weapon && 'armorType' in entry && (
<div className="mt-md">
<Field label="Armor">
{(id) => (
<select id={id} value={entry.armorType ?? ''} disabled={patch.isPending} onChange={(e) => patch.mutate({ id: entry.id, armorType: (e.target.value || null) as ArmorType | null }, { onError })}>
<option value="">Not armor</option>
<option value="LIGHT">Light armor</option>
<option value="MEDIUM">Medium armor</option>
<option value="HEAVY">Heavy armor</option>
<option value="SHIELD">Shield</option>
</select>
)}
</Field>
<p className="combatant-sub mt-sm">
Its AC is still set below, as +AC. This says what kind it is, for the class features that depend on what is worn: a barbarian’s and a
monk’s Unarmored Defense and a draconic sorcerer’s scales stop while armor is equipped, a monk’s also behind a shield, and a
barbarian’s extra speed stops in heavy armor.
</p>
</div>
)}
<div className="section-label mt-md">While equipped</div>
<p className="combatant-sub" style={{ marginTop: 0 }}>
These count for a character only while it is equipped and they are high enough level to use it. Armor is an item with
@@ -259,6 +259,7 @@ export function ItemsSection({ campaignId, entries, onOpen, formRequested, onFor
isStartingOption: fd.get('isStartingOption') === 'on',
effects: [],
effectNote: null,
armorType: null,
});
close();
onOpen({ kind: 'item', id: item.id, isNew: true });
+3 -1
View File
@@ -1589,6 +1589,8 @@ function BuildPreview({
for (const ability of all) if (!abilityList.some((a) => a.name === ability.name)) abilityList.push(ability);
}
const wisMod = abilityFinals.WIS !== undefined ? abilityModifier(abilityFinals.WIS) : null;
// Unarmored Defense, as the sheet will count it: a new character wears nothing yet.
const unarmoredDefense = Math.max(0, characterClass === 'BARBARIAN' ? (conMod ?? 0) : characterClass === 'MONK' ? (wisMod ?? 0) : 0);
return (
<div className="card">
@@ -1651,7 +1653,7 @@ function BuildPreview({
<span className="stat-label">
AC <InfoTip term="AC" />
</span>
<div className="stat-value">{dexMod !== null ? armorClass(dexMod, armorBonus) : '—'}</div>
<div className="stat-value">{dexMod !== null ? armorClass(dexMod, armorBonus) + unarmoredDefense : '—'}</div>
</div>
<div>
<span className="stat-label">
+21
View File
@@ -682,6 +682,27 @@ export function CharacterSheetPage() {
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
)}
{sheet.features.length > 0 && (
<div className="card mt-md">
<div className="card-title">From Class Features</div>
{sheet.features.map((feature) => (
<div key={`${feature.source}:${feature.name}:${feature.text}`} className="detail-row">
<span>
<strong>{feature.name}</strong>
<span className="combatant-sub" style={{ marginLeft: 6 }}>
{feature.source}
</span>
</span>
<span className="combatant-sub">{feature.text}</span>
</div>
))}
<p className="subtitle" style={{ margin: '0.4rem 0 0' }}>
Already counted in every number on this sheet: nothing to add by hand. What is worn decides some of them (armor and shields are
items marked as such); a speed set by the DM is used as it is.
</p>
</div>
)}
{(sheet.gear.effects.length > 0 || sheet.gear.notes.length > 0) && (
<div className="card mt-md">
<div className="card-title">From Equipped Gear</div>