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:
+128
-59
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user