feat: temp HP, character isNpc, initiative box
Temp HP: - setTempHp() in shared/turn.js (replaces, no stacking) - Damage absorbs temp HP first (both 5e + generic rulesets) - Inline temp HP input on participant card (cyan when active) - Temp HP field in edit modal - makeParticipant tempHp field (default 0) - 8 shared tests Character isNpc: - isNpc field on character roster model - buildCharacterParticipant: type 'npc' when isNpc - Add form checkbox + edit form checkbox - NPC badge on character list row - Writeback preserves isNpc Initiative box: - Amber-bordered box around initiative input - Gold text (amber-300), disabled stays gold - Stone-950 bg matches page - Bumped maxHp (stone-200) + tempHp (stone-300/cyan) visibility
This commit is contained in:
+70
-7
@@ -589,6 +589,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
|
||||
const [ac, setAc] = useState(participant.ac != null ? participant.ac : '');
|
||||
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
||||
const [tempHp, setTempHp] = useState(participant.tempHp || 0);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -608,6 +609,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
maxHp: finalMaxHp,
|
||||
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
|
||||
ac: ac !== '' ? parseInt(ac, 10) : null,
|
||||
tempHp: Math.max(0, parseInt(tempHp, 10) || 0),
|
||||
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
|
||||
});
|
||||
};
|
||||
@@ -666,6 +668,20 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-stone-300">Temp HP</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tempHp}
|
||||
onChange={(e) => setTempHp(e.target.value)}
|
||||
min="0"
|
||||
placeholder="0"
|
||||
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-cyan-600 focus:border-cyan-600 sm:text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
{(participant.type === 'monster' || participant.type === 'npc') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-300 mb-1">HP Formula</label>
|
||||
@@ -736,6 +752,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
|
||||
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
|
||||
const [defaultAc, setDefaultAc] = useState('');
|
||||
const [isCharacterNpc, setIsCharacterNpc] = useState(false);
|
||||
const [editingCharacter, setEditingCharacter] = useState(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
@@ -782,7 +799,8 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
name: characterName.trim(),
|
||||
defaultMaxHp: hp,
|
||||
defaultInitMod: initMod,
|
||||
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null
|
||||
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null,
|
||||
isNpc: isCharacterNpc || false,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -793,12 +811,13 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
setDefaultMaxHp(DEFAULT_MAX_HP);
|
||||
setDefaultInitMod(DEFAULT_INIT_MOD);
|
||||
setDefaultAc('');
|
||||
setIsCharacterNpc(false);
|
||||
} catch (err) {
|
||||
console.error("Error adding character:", err);
|
||||
showToast("Failed to add character. Please try again."); }
|
||||
};
|
||||
|
||||
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp) => {
|
||||
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp, newIsNpc) => {
|
||||
if (!db || !newName.trim() || !campaignId) return;
|
||||
|
||||
const hp = parseInt(newDefaultMaxHp, 10);
|
||||
@@ -815,7 +834,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
|
||||
const updatedCharacters = campaignCharacters.map(c =>
|
||||
c.id === characterId
|
||||
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null }
|
||||
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null, isNpc: newIsNpc != null ? newIsNpc : (c.isNpc || false) }
|
||||
: c
|
||||
);
|
||||
|
||||
@@ -924,6 +943,15 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
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>
|
||||
<label className="col-span-2 sm:col-span-12 flex items-center gap-2 text-xs font-medium text-stone-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isCharacterNpc}
|
||||
onChange={(e) => setIsCharacterNpc(e.target.checked)}
|
||||
className="w-4 h-4 accent-amber-600"
|
||||
/>
|
||||
Long-lived NPC (death saves like character, red name in combat)
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
@@ -959,7 +987,8 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
editingCharacter.defaultMaxHp,
|
||||
editingCharacter.defaultInitMod,
|
||||
editingCharacter.defaultAc,
|
||||
editingCharacter.defaultCurrentHp
|
||||
editingCharacter.defaultCurrentHp,
|
||||
editingCharacter.isNpc
|
||||
);
|
||||
}}
|
||||
className="flex-grow flex flex-wrap gap-2 items-center"
|
||||
@@ -1013,6 +1042,15 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
title="AC"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1 text-[10px] text-stone-400" title="Long-lived NPC">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingCharacter.isNpc || false}
|
||||
onChange={(e) => setEditingCharacter({ ...editingCharacter, isNpc: e.target.checked })}
|
||||
className="w-3 h-3 accent-red-600"
|
||||
/>
|
||||
NPC
|
||||
</label>
|
||||
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
|
||||
<Save size={18} />
|
||||
</button>
|
||||
@@ -1028,6 +1066,9 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
<>
|
||||
<div className="flex items-center mr-auto">
|
||||
<span className="text-stone-100 mr-2">{character.name}</span>
|
||||
{character.isNpc && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-red-900 text-red-200 text-xs font-medium border border-red-600" title="Long-lived NPC">NPC</span>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<span className="px-1.5 py-0.5 rounded bg-red-900/60 text-red-200 text-xs font-medium">HP {character.defaultMaxHp || '?'}</span>
|
||||
{character.defaultCurrentHp != null && character.defaultCurrentHp !== character.defaultMaxHp && (
|
||||
@@ -1376,6 +1417,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
}
|
||||
};
|
||||
|
||||
const handleInlineTempHp = async (participantId, value) => {
|
||||
const n = Math.max(0, parseInt(value, 10) || 0);
|
||||
try {
|
||||
await updateParticipant(encounter, participantId, { tempHp: n }, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
showToast('Failed to update Temp HP.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeathSaveChange = async (participantId, outcome) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
@@ -1778,7 +1828,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
|
||||
</p>
|
||||
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-flex items-center px-0.5 border border-amber-500 bg-stone-950 text-amber-300 rounded">
|
||||
<label htmlFor={`init-${p.id}`} className="sr-only">Initiative</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -1792,7 +1842,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }}
|
||||
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
|
||||
className="w-7 bg-transparent text-white text-base text-center border-b border-transparent hover:border-stone-500 focus:border-amber-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
className="w-7 bg-transparent text-amber-300 text-lg font-bold text-center focus:outline-none disabled:text-amber-300 disabled:opacity-100 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
aria-label={`Initiative for ${p.name}`}
|
||||
/>
|
||||
</span>
|
||||
@@ -1816,10 +1866,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }}
|
||||
onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }}
|
||||
className="w-10 bg-transparent text-stone-400 text-base text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
className="w-10 bg-transparent text-stone-200 text-base text-center border-b border-transparent hover:border-stone-400 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
aria-label={`Max HP for ${p.name}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="inline-flex items-baseline gap-0.5 ml-1" title="Temporary HP">
|
||||
<span className="text-[10px] text-cyan-400 font-semibold tracking-wide">+</span>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={p.tempHp || 0}
|
||||
key={`temphp-${p.tempHp || 0}`}
|
||||
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Temp HP'); }}
|
||||
onBlur={(e) => { if (e.target.value !== String(p.tempHp || 0)) handleInlineTempHp(p.id, e.target.value); handleFieldBlur(); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.tempHp || 0; }}
|
||||
className={`w-7 bg-transparent text-base text-center border-b focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${(p.tempHp || 0) > 0 ? 'text-cyan-200 border-cyan-500' : 'text-stone-300 border-transparent hover:border-stone-400'}`}
|
||||
aria-label={`Temp HP for ${p.name}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1">
|
||||
{participantStatus === 'dead' && (
|
||||
<button onClick={() => handleRevive(p.id)} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive (clear dead status)">
|
||||
|
||||
Reference in New Issue
Block a user