Add AC field (character, monster/npc, participant edit); layout polish

AC (Armor Class) optional field across all participant entry points:
- shared: ac field on makeParticipant + buildMonsterParticipant +
  buildCharacterParticipant, defaults null
- CharacterManager: defaultAc state + add form field + inline edit field +
  display in character list
- Monster add form: AC field
- EditParticipantModal: AC field next to Initiative
- ParticipantManager (DM list): AC badge on name row (sky-blue, stylized,
  large value, small label) for at-a-glance reading
- Player display: no AC (DM only)

Layout polish:
- Add participants form: 12-col grid, 5 fields single row (Init Mod, Initiative,
  AC, Max HP, HP Formula), shrunk from oversized fields
- Character add form: 12-col grid, name grows (col-span-6), Init Mod/AC/HP
  small right-aligned, order matches add participants
- Character inline edit: labels added (Name/HP/Init Mod/AC), name flex-grows
- HP Formula: label trimmed (example moved to placeholder 'e.g. 2d6+9'),
  Reroll button in edit modal
- ParticipantManager init input shrunk (w-8, centered)

Tests: 6 new AC builder tests (turn.ac.test.js). Existing test labels updated
for renamed fields.
This commit is contained in:
david raistrick
2026-07-08 12:23:19 -04:00
parent 4a92c667c5
commit 863e8b3719
6 changed files with 172 additions and 64 deletions
+3 -2
View File
@@ -9,12 +9,13 @@ fullscreen and dont lock on main app dm view and the no-game-player view
also better vert tab layout - labelt friendly
needs AC for players dude
x needs AC for players dude
and quick entry hp
hp do not carry from encounter to ecnounter!!!
^feature to add back to campaign character after encounter?
maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront)
hp wont go over max and no temp hp support
+35
View File
@@ -0,0 +1,35 @@
// AC field on participant + builders.
const shared = require('@ttrpg/shared');
const { makeParticipant, buildMonsterParticipant, buildCharacterParticipant, rollHpFormula } = shared;
describe('AC field', () => {
test('makeParticipant accepts ac', () => {
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15, ac: 13 });
expect(p.ac).toBe(13);
});
test('makeParticipant ac defaults null', () => {
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15 });
expect(p.ac).toBeNull();
});
test('buildMonsterParticipant accepts ac', () => {
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7, initMod: 2, ac: 15 });
expect(participant.ac).toBe(15);
});
test('buildMonsterParticipant ac defaults null', () => {
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7 });
expect(participant.ac).toBeNull();
});
test('buildCharacterParticipant accepts character ac', () => {
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2, defaultAc: 18 });
expect(participant.ac).toBe(18);
});
test('buildCharacterParticipant ac defaults null when unset', () => {
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30 });
expect(participant.ac).toBeNull();
});
});
+4 -1
View File
@@ -361,6 +361,7 @@ function makeParticipant(opts) {
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
deathSaveFailures: opts.deathSaveFailures || 0,
hpFormula: opts.hpFormula || null,
ac: opts.ac !== undefined ? opts.ac : null,
};
}
@@ -377,12 +378,13 @@ function buildCharacterParticipant(character) {
initiative: finalInitiative,
maxHp,
currentHp: maxHp,
ac: character.defaultAc !== undefined ? character.defaultAc : null,
}),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
};
}
function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) {
function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula, ac }) {
const initiativeRoll = rollD20();
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
const finalInitiative = initiativeRoll + modifier;
@@ -406,6 +408,7 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) {
maxHp: hp,
currentHp: hp,
hpFormula: hpFormula || null,
ac: ac !== undefined ? ac : null,
}),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
};
+128 -59
View File
@@ -583,6 +583,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
const [currentHp, setCurrentHp] = useState(participant.currentHp);
const [maxHp, setMaxHp] = useState(participant.maxHp);
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
const [ac, setAc] = useState(participant.ac != null ? participant.ac : '');
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
const handleSubmit = (e) => {
@@ -602,6 +603,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
currentHp: finalCurrentHp,
maxHp: finalMaxHp,
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
ac: ac !== '' ? parseInt(ac, 10) : null,
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
});
};
@@ -618,15 +620,27 @@ function EditParticipantModal({ participant, onClose, onSave }) {
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div>
<label htmlFor="edit-initiative" className="block text-sm font-medium text-stone-300">Initiative</label>
<input
type="number"
id="edit-initiative"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
<div className="flex gap-4">
<div className="flex-1">
<label htmlFor="edit-initiative" className="block text-sm font-medium text-stone-300">Initiative</label>
<input
type="number"
id="edit-initiative"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-stone-300">AC</label>
<input
type="number"
value={ac}
onChange={(e) => setAc(e.target.value)}
placeholder="optional"
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
</div>
<div className="flex gap-4">
<div className="flex-1">
@@ -656,7 +670,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
type="text"
value={hpFormula}
onChange={(e) => setHpFormula(e.target.value)}
placeholder="2d6+9"
placeholder="e.g. 2d6+9"
className="flex-1 px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
<button
@@ -717,6 +731,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
const [characterName, setCharacterName] = useState('');
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
const [defaultAc, setDefaultAc] = useState('');
const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
@@ -762,7 +777,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
id: generateId(),
name: characterName.trim(),
defaultMaxHp: hp,
defaultInitMod: initMod
defaultInitMod: initMod,
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null
};
try {
@@ -772,12 +788,13 @@ function CharacterManager({ campaignId, campaignCharacters }) {
setCharacterName('');
setDefaultMaxHp(DEFAULT_MAX_HP);
setDefaultInitMod(DEFAULT_INIT_MOD);
setDefaultAc('');
} catch (err) {
console.error("Error adding character:", err);
showToast("Failed to add character. Please try again."); }
};
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod) => {
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc) => {
if (!db || !newName.trim() || !campaignId) return;
const hp = parseInt(newDefaultMaxHp, 10);
@@ -794,7 +811,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
const updatedCharacters = campaignCharacters.map(c =>
c.id === characterId
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod }
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null }
: c
);
@@ -843,8 +860,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
{isOpen && (
<>
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 items-end">
<div className="sm:col-span-1">
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-2 sm:grid-cols-12 gap-2 mb-4 items-end">
<div className="col-span-2 sm:col-span-6">
<label htmlFor="characterName" className="block text-xs font-medium text-stone-400">
Name
</label>
@@ -857,19 +874,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="w-full sm:w-auto">
<label htmlFor="defaultMaxHp" className="block text-xs font-medium text-stone-400">
Default HP
</label>
<input
type="number"
id="defaultMaxHp"
value={defaultMaxHp}
onChange={(e) => setDefaultMaxHp(e.target.value)}
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="w-full sm:w-auto">
<div className="sm:col-span-2">
<label htmlFor="defaultInitMod" className="block text-xs font-medium text-stone-400">
Init Mod
</label>
@@ -881,9 +886,33 @@ function CharacterManager({ campaignId, campaignCharacters }) {
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="sm:col-span-2">
<label htmlFor="defaultAc" className="block text-xs font-medium text-stone-400">
AC
</label>
<input
type="number"
id="defaultAc"
value={defaultAc}
onChange={(e) => setDefaultAc(e.target.value)}
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="sm:col-span-2">
<label htmlFor="defaultMaxHp" className="block text-xs font-medium text-stone-400">
HP
</label>
<input
type="number"
id="defaultMaxHp"
value={defaultMaxHp}
onChange={(e) => setDefaultMaxHp(e.target.value)}
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<button
type="submit"
className="sm:col-span-3 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
className="col-span-2 sm:col-span-12 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
>
<PlusCircle size={18} className="mr-1" /> Add Character
</button>
@@ -914,31 +943,51 @@ function CharacterManager({ campaignId, campaignCharacters }) {
character.id,
editingCharacter.name,
editingCharacter.defaultMaxHp,
editingCharacter.defaultInitMod
editingCharacter.defaultInitMod,
editingCharacter.defaultAc
);
}}
className="flex-grow flex flex-wrap gap-2 items-center"
>
<input
type="text"
value={editingCharacter.name}
onChange={(e) => setEditingCharacter({ ...editingCharacter, name: e.target.value })}
className="flex-grow min-w-[100px] px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
/>
<input
type="number"
value={editingCharacter.defaultMaxHp}
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })}
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
title="Default Max HP"
/>
<input
type="number"
value={editingCharacter.defaultInitMod}
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })}
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
title="Default Init Mod"
/>
<div className="flex flex-col flex-grow min-w-[100px]">
<label className="text-[10px] text-stone-400 mb-0.5">Name</label>
<input
type="text"
value={editingCharacter.name}
onChange={(e) => setEditingCharacter({ ...editingCharacter, name: e.target.value })}
className="w-full px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
/>
</div>
<div className="flex flex-col">
<label className="text-[10px] text-stone-400 mb-0.5">HP</label>
<input
type="number"
value={editingCharacter.defaultMaxHp}
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })}
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
title="Default Max HP"
/>
</div>
<div className="flex flex-col">
<label className="text-[10px] text-stone-400 mb-0.5">Init</label>
<input
type="number"
value={editingCharacter.defaultInitMod}
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })}
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
title="Default Init Mod"
/>
</div>
<div className="flex flex-col">
<label className="text-[10px] text-stone-400 mb-0.5">AC</label>
<input
type="number"
value={editingCharacter.defaultAc ?? ''}
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultAc: e.target.value })}
className="w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
title="AC"
/>
</div>
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
<Save size={18} />
</button>
@@ -955,7 +1004,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
<span className="text-stone-100">
{character.name}{' '}
<span className="text-xs text-stone-400">
(HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)})
(HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)}{character.defaultAc != null ? `, AC: ${character.defaultAc}` : ''})
</span>
</span>
<div className="flex space-x-2">
@@ -964,7 +1013,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
id: character.id,
name: character.name,
defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP,
defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD
defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD,
defaultAc: character.defaultAc ?? ''
})}
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
aria-label="Edit character"
@@ -1013,6 +1063,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const [maxHp, setMaxHp] = useState('');
const [hpFormula, setHpFormula] = useState('');
const [manualInitiative, setManualInitiative] = useState('');
const [monsterAc, setMonsterAc] = useState('');
const [asNpc, setAsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({});
@@ -1095,6 +1146,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
maxHp: currentMaxHp,
currentHp: currentMaxHp,
hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null,
ac: participantType === 'monster' && monsterAc !== '' ? parseInt(monsterAc, 10) : (participantType === 'character' ? (campaignCharacters.find(c => c.id === selectedCharacterId)?.defaultAc ?? null) : null),
conditions: [],
isActive: true,
status: 'conscious',
@@ -1120,6 +1172,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
setHpFormula('');
setSelectedCharacterId('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
setMonsterAc('');
setAsNpc(false);
setManualInitiative('');
} catch (err) {
@@ -1411,7 +1464,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="md:col-span-3">
<div className="md:col-span-2">
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-stone-300">
Init Mod
</label>
@@ -1423,7 +1476,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="md:col-span-3">
<div className="md:col-span-2">
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
</label>
@@ -1436,7 +1489,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="md:col-span-3">
<div className="md:col-span-2">
<label htmlFor="monsterAc" className="block text-sm font-medium text-stone-300">AC</label>
<input
type="number"
id="monsterAc"
value={monsterAc}
onChange={(e) => setMonsterAc(e.target.value)}
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="md:col-span-2">
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
Max HP
</label>
@@ -1448,16 +1511,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="md:col-span-3">
<div className="md:col-span-4">
<label htmlFor="monsterHpFormula" className="block text-sm font-medium text-stone-300">
HP Formula (e.g. 2d6+9)
HP Formula
</label>
<input
type="text"
id="monsterHpFormula"
value={hpFormula}
onChange={(e) => setHpFormula(e.target.value)}
placeholder="2d6+9"
placeholder="e.g. 2d6+9"
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
@@ -1579,6 +1642,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
<p className="font-semibold text-lg text-white">
{isZeroHp && <span className="mr-2"></span>}
{p.name} <span className="text-xs">({participantDisplayType})</span>
{p.ac != null && (
<span className="ml-2 inline-flex items-baseline gap-1 px-2 py-0.5 bg-sky-900 border border-sky-500 rounded">
<span className="text-[10px] text-sky-300 font-semibold tracking-wide">AC</span>
<span className="text-sky-100 text-lg font-bold leading-none">{p.ac}</span>
</span>
)}
{isCurrentTurn && !encounter.isPaused && (
<span className="ml-2 px-2 py-0.5 bg-yellow-400 text-black text-xs font-bold rounded-full inline-flex items-center">
<Zap size={12} className="mr-1" /> CURRENT
@@ -1604,7 +1673,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onFocus={(e) => e.target.select()}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
className="w-10 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-8 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={`Initiative for ${p.name}`}
/>
</span>
+1 -1
View File
@@ -62,7 +62,7 @@ describe('Campaign -> Firebase', () => {
// CharacterManager form
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } });
fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: '25' } });
fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
+1 -1
View File
@@ -20,7 +20,7 @@ async function addCharacterToEncounter(name = 'Hero', hp = 10) {
await selectCampaignByName(`DS-${name}`);
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: String(hp) } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;