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
+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>