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:
@@ -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
|
also better vert tab layout - labelt friendly
|
||||||
|
|
||||||
needs AC for players dude
|
x needs AC for players dude
|
||||||
|
|
||||||
and quick entry hp
|
and quick entry hp
|
||||||
|
|
||||||
hp do not carry from encounter to ecnounter!!!
|
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
|
hp wont go over max and no temp hp support
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -361,6 +361,7 @@ function makeParticipant(opts) {
|
|||||||
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
|
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
|
||||||
deathSaveFailures: opts.deathSaveFailures || 0,
|
deathSaveFailures: opts.deathSaveFailures || 0,
|
||||||
hpFormula: opts.hpFormula || null,
|
hpFormula: opts.hpFormula || null,
|
||||||
|
ac: opts.ac !== undefined ? opts.ac : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,12 +378,13 @@ function buildCharacterParticipant(character) {
|
|||||||
initiative: finalInitiative,
|
initiative: finalInitiative,
|
||||||
maxHp,
|
maxHp,
|
||||||
currentHp: maxHp,
|
currentHp: maxHp,
|
||||||
|
ac: character.defaultAc !== undefined ? character.defaultAc : null,
|
||||||
}),
|
}),
|
||||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
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 initiativeRoll = rollD20();
|
||||||
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
||||||
const finalInitiative = initiativeRoll + modifier;
|
const finalInitiative = initiativeRoll + modifier;
|
||||||
@@ -406,6 +408,7 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) {
|
|||||||
maxHp: hp,
|
maxHp: hp,
|
||||||
currentHp: hp,
|
currentHp: hp,
|
||||||
hpFormula: hpFormula || null,
|
hpFormula: hpFormula || null,
|
||||||
|
ac: ac !== undefined ? ac : null,
|
||||||
}),
|
}),
|
||||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
|
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
|
||||||
};
|
};
|
||||||
|
|||||||
+128
-59
@@ -583,6 +583,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
const [currentHp, setCurrentHp] = useState(participant.currentHp);
|
const [currentHp, setCurrentHp] = useState(participant.currentHp);
|
||||||
const [maxHp, setMaxHp] = useState(participant.maxHp);
|
const [maxHp, setMaxHp] = useState(participant.maxHp);
|
||||||
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
|
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
|
||||||
|
const [ac, setAc] = useState(participant.ac != null ? participant.ac : '');
|
||||||
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
@@ -602,6 +603,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
currentHp: finalCurrentHp,
|
currentHp: finalCurrentHp,
|
||||||
maxHp: finalMaxHp,
|
maxHp: finalMaxHp,
|
||||||
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
|
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,
|
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"
|
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>
|
<div className="flex gap-4">
|
||||||
<label htmlFor="edit-initiative" className="block text-sm font-medium text-stone-300">Initiative</label>
|
<div className="flex-1">
|
||||||
<input
|
<label htmlFor="edit-initiative" className="block text-sm font-medium text-stone-300">Initiative</label>
|
||||||
type="number"
|
<input
|
||||||
id="edit-initiative"
|
type="number"
|
||||||
value={initiative}
|
id="edit-initiative"
|
||||||
onChange={(e) => setInitiative(e.target.value)}
|
value={initiative}
|
||||||
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"
|
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>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -656,7 +670,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={hpFormula}
|
value={hpFormula}
|
||||||
onChange={(e) => setHpFormula(e.target.value)}
|
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"
|
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
|
<button
|
||||||
@@ -717,6 +731,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
const [characterName, setCharacterName] = useState('');
|
const [characterName, setCharacterName] = useState('');
|
||||||
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
|
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
|
||||||
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
|
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
|
||||||
|
const [defaultAc, setDefaultAc] = useState('');
|
||||||
const [editingCharacter, setEditingCharacter] = useState(null);
|
const [editingCharacter, setEditingCharacter] = useState(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
@@ -762,7 +777,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
id: generateId(),
|
id: generateId(),
|
||||||
name: characterName.trim(),
|
name: characterName.trim(),
|
||||||
defaultMaxHp: hp,
|
defaultMaxHp: hp,
|
||||||
defaultInitMod: initMod
|
defaultInitMod: initMod,
|
||||||
|
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -772,12 +788,13 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
setCharacterName('');
|
setCharacterName('');
|
||||||
setDefaultMaxHp(DEFAULT_MAX_HP);
|
setDefaultMaxHp(DEFAULT_MAX_HP);
|
||||||
setDefaultInitMod(DEFAULT_INIT_MOD);
|
setDefaultInitMod(DEFAULT_INIT_MOD);
|
||||||
|
setDefaultAc('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error adding character:", err);
|
console.error("Error adding character:", err);
|
||||||
showToast("Failed to add character. Please try again."); }
|
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;
|
if (!db || !newName.trim() || !campaignId) return;
|
||||||
|
|
||||||
const hp = parseInt(newDefaultMaxHp, 10);
|
const hp = parseInt(newDefaultMaxHp, 10);
|
||||||
@@ -794,7 +811,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
|
|
||||||
const updatedCharacters = campaignCharacters.map(c =>
|
const updatedCharacters = campaignCharacters.map(c =>
|
||||||
c.id === characterId
|
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
|
: c
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -843,8 +860,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 items-end">
|
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-2 sm:grid-cols-12 gap-2 mb-4 items-end">
|
||||||
<div className="sm:col-span-1">
|
<div className="col-span-2 sm:col-span-6">
|
||||||
<label htmlFor="characterName" className="block text-xs font-medium text-stone-400">
|
<label htmlFor="characterName" className="block text-xs font-medium text-stone-400">
|
||||||
Name
|
Name
|
||||||
</label>
|
</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"
|
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>
|
||||||
<div className="w-full sm:w-auto">
|
<div className="sm:col-span-2">
|
||||||
<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">
|
|
||||||
<label htmlFor="defaultInitMod" className="block text-xs font-medium text-stone-400">
|
<label htmlFor="defaultInitMod" className="block text-xs font-medium text-stone-400">
|
||||||
Init Mod
|
Init Mod
|
||||||
</label>
|
</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"
|
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>
|
||||||
|
<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
|
<button
|
||||||
type="submit"
|
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
|
<PlusCircle size={18} className="mr-1" /> Add Character
|
||||||
</button>
|
</button>
|
||||||
@@ -914,31 +943,51 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
character.id,
|
character.id,
|
||||||
editingCharacter.name,
|
editingCharacter.name,
|
||||||
editingCharacter.defaultMaxHp,
|
editingCharacter.defaultMaxHp,
|
||||||
editingCharacter.defaultInitMod
|
editingCharacter.defaultInitMod,
|
||||||
|
editingCharacter.defaultAc
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="flex-grow flex flex-wrap gap-2 items-center"
|
className="flex-grow flex flex-wrap gap-2 items-center"
|
||||||
>
|
>
|
||||||
<input
|
<div className="flex flex-col flex-grow min-w-[100px]">
|
||||||
type="text"
|
<label className="text-[10px] text-stone-400 mb-0.5">Name</label>
|
||||||
value={editingCharacter.name}
|
<input
|
||||||
onChange={(e) => setEditingCharacter({ ...editingCharacter, name: e.target.value })}
|
type="text"
|
||||||
className="flex-grow min-w-[100px] px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
value={editingCharacter.name}
|
||||||
/>
|
onChange={(e) => setEditingCharacter({ ...editingCharacter, name: e.target.value })}
|
||||||
<input
|
className="w-full px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
||||||
type="number"
|
/>
|
||||||
value={editingCharacter.defaultMaxHp}
|
</div>
|
||||||
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })}
|
<div className="flex flex-col">
|
||||||
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
<label className="text-[10px] text-stone-400 mb-0.5">HP</label>
|
||||||
title="Default Max HP"
|
<input
|
||||||
/>
|
type="number"
|
||||||
<input
|
value={editingCharacter.defaultMaxHp}
|
||||||
type="number"
|
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })}
|
||||||
value={editingCharacter.defaultInitMod}
|
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
||||||
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })}
|
title="Default Max HP"
|
||||||
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">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">
|
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
|
||||||
<Save size={18} />
|
<Save size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -955,7 +1004,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
<span className="text-stone-100">
|
<span className="text-stone-100">
|
||||||
{character.name}{' '}
|
{character.name}{' '}
|
||||||
<span className="text-xs text-stone-400">
|
<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>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
@@ -964,7 +1013,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
id: character.id,
|
id: character.id,
|
||||||
name: character.name,
|
name: character.name,
|
||||||
defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP,
|
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"
|
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
|
||||||
aria-label="Edit character"
|
aria-label="Edit character"
|
||||||
@@ -1013,6 +1063,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
const [maxHp, setMaxHp] = useState('');
|
const [maxHp, setMaxHp] = useState('');
|
||||||
const [hpFormula, setHpFormula] = useState('');
|
const [hpFormula, setHpFormula] = useState('');
|
||||||
const [manualInitiative, setManualInitiative] = useState('');
|
const [manualInitiative, setManualInitiative] = useState('');
|
||||||
|
const [monsterAc, setMonsterAc] = useState('');
|
||||||
const [asNpc, setAsNpc] = useState(false);
|
const [asNpc, setAsNpc] = useState(false);
|
||||||
const [editingParticipant, setEditingParticipant] = useState(null);
|
const [editingParticipant, setEditingParticipant] = useState(null);
|
||||||
const [hpChangeValues, setHpChangeValues] = useState({});
|
const [hpChangeValues, setHpChangeValues] = useState({});
|
||||||
@@ -1095,6 +1146,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
maxHp: currentMaxHp,
|
maxHp: currentMaxHp,
|
||||||
currentHp: currentMaxHp,
|
currentHp: currentMaxHp,
|
||||||
hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null,
|
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: [],
|
conditions: [],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
status: 'conscious',
|
status: 'conscious',
|
||||||
@@ -1120,6 +1172,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
setHpFormula('');
|
setHpFormula('');
|
||||||
setSelectedCharacterId('');
|
setSelectedCharacterId('');
|
||||||
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
||||||
|
setMonsterAc('');
|
||||||
setAsNpc(false);
|
setAsNpc(false);
|
||||||
setManualInitiative('');
|
setManualInitiative('');
|
||||||
} catch (err) {
|
} 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"
|
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>
|
||||||
<div className="md:col-span-3">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-stone-300">
|
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-stone-300">
|
||||||
Init Mod
|
Init Mod
|
||||||
</label>
|
</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"
|
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>
|
||||||
<div className="md:col-span-3">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
|
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
|
||||||
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
|
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
|
||||||
</label>
|
</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"
|
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>
|
||||||
<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">
|
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
|
||||||
Max HP
|
Max HP
|
||||||
</label>
|
</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"
|
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>
|
||||||
<div className="md:col-span-3">
|
<div className="md:col-span-4">
|
||||||
<label htmlFor="monsterHpFormula" className="block text-sm font-medium text-stone-300">
|
<label htmlFor="monsterHpFormula" className="block text-sm font-medium text-stone-300">
|
||||||
HP Formula (e.g. 2d6+9)
|
HP Formula
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="monsterHpFormula"
|
id="monsterHpFormula"
|
||||||
value={hpFormula}
|
value={hpFormula}
|
||||||
onChange={(e) => setHpFormula(e.target.value)}
|
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"
|
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>
|
||||||
@@ -1579,6 +1642,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
<p className="font-semibold text-lg text-white">
|
<p className="font-semibold text-lg text-white">
|
||||||
{isZeroHp && <span className="mr-2">☠️</span>}
|
{isZeroHp && <span className="mr-2">☠️</span>}
|
||||||
{p.name} <span className="text-xs">({participantDisplayType})</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 && (
|
{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">
|
<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
|
<Zap size={12} className="mr-1" /> CURRENT
|
||||||
@@ -1604,7 +1673,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
onFocus={(e) => e.target.select()}
|
onFocus={(e) => e.target.select()}
|
||||||
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
|
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
|
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}`}
|
aria-label={`Initiative for ${p.name}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('Campaign -> Firebase', () => {
|
|||||||
|
|
||||||
// CharacterManager form
|
// CharacterManager form
|
||||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
|
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.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async function addCharacterToEncounter(name = 'Hero', hp = 10) {
|
|||||||
await selectCampaignByName(`DS-${name}`);
|
await selectCampaignByName(`DS-${name}`);
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 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 }));
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
|
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;
|
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;
|
||||||
|
|||||||
Reference in New Issue
Block a user