Implement D&D 5e death-save state machine and cleanup combat ordering
- Add status-driven death-save model: - status: conscious | dying | stable | dead - deathSaveSuccesses/deathSaveFailures as display counters - remove old death-save fields as source of truth - Add death-save actions: - success, fail, nat1, nat20 - stabilize participant - revive dead participant to 0 HP, stable, unconscious - Apply 5e HP transition rules: - characters and NPCs at 0 HP become dying - stable/dying participants gain unconscious condition - healing clears death saves and returns conscious - massive damage kills - non-NPC monsters at 0 HP become dead and inactive - Split NPCs from monsters with type="npc": - NPCs use death saves like characters - NPCs remain DM-controlled/monster-colored in UI - new NPCs no longer persist isNpc flag - Keep dead PCs/NPCs in encounter and initiative until DM removes or deactivates - Allow DM active/inactive toggle for dead participants - Hide inactive participants from player display - Improve DM and player death-state UI: - show Dying/Dead/Unconscious states consistently - add revive button for dead participants - distinguish dead visual from inactive visual in DM view - Fix initiative drag/reorder behavior: - downward drag now changes order instead of no-op - paused combat can reorder across current turn pointer - turnOrderIds stay synced to participants order - Persist campaign collapse state in localStorage - Update death-save docs and encounter builder docs - Add/expand tests for: - death saves and HP transitions - dead/active/inactive behavior - NPC death-save behavior - player display visibility - drag reorder semantics - logging expectations Tests: - npm run test:all - app: 94 passed - shared: 158 passed, 5 skipped - server: 32 passed
This commit is contained in:
+144
-98
@@ -123,6 +123,8 @@ const {
|
||||
toggleParticipantActive: combatToggleActive,
|
||||
applyHpChange: combatApplyHpChange,
|
||||
deathSave: combatDeathSave,
|
||||
stabilizeParticipant,
|
||||
reviveParticipant,
|
||||
toggleCondition: combatToggleCondition,
|
||||
reorderParticipants,
|
||||
} = shared;
|
||||
@@ -550,7 +552,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
const [initiative, setInitiative] = useState(participant.initiative);
|
||||
const [currentHp, setCurrentHp] = useState(participant.currentHp);
|
||||
const [maxHp, setMaxHp] = useState(participant.maxHp);
|
||||
const [isNpc, setIsNpc] = useState(participant.type === 'monster' ? (participant.isNpc || false) : false);
|
||||
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -559,7 +561,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
initiative: parseInt(initiative, 10),
|
||||
currentHp: parseInt(currentHp, 10),
|
||||
maxHp: parseInt(maxHp, 10),
|
||||
isNpc: participant.type === 'monster' ? isNpc : false,
|
||||
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -605,13 +607,13 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{participant.type === 'monster' && (
|
||||
{(participant.type === 'monster' || participant.type === 'npc') && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="editIsNpc"
|
||||
checked={isNpc}
|
||||
onChange={(e) => setIsNpc(e.target.checked)}
|
||||
checked={asNpc}
|
||||
onChange={(e) => setAsNpc(e.target.checked)}
|
||||
className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500"
|
||||
/>
|
||||
<label htmlFor="editIsNpc" className="ml-2 block text-sm text-stone-300">
|
||||
@@ -910,7 +912,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
|
||||
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
|
||||
const [manualInitiative, setManualInitiative] = useState('');
|
||||
const [isNpc, setIsNpc] = useState(false);
|
||||
const [asNpc, setAsNpc] = useState(false);
|
||||
const [editingParticipant, setEditingParticipant] = useState(null);
|
||||
const [hpChangeValues, setHpChangeValues] = useState({});
|
||||
const [draggedItemId, setDraggedItemId] = useState(null);
|
||||
@@ -940,7 +942,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
} else {
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
}
|
||||
setIsNpc(false);
|
||||
setAsNpc(false);
|
||||
} else if (participantType === 'monster') {
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
||||
@@ -956,7 +958,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const initiativeRoll = rollD20();
|
||||
let modifier = 0;
|
||||
let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP;
|
||||
let participantIsNpc = false;
|
||||
let participantAsNpc = false;
|
||||
const manualInit = manualInitiative !== '' && !isNaN(parseInt(manualInitiative, 10));
|
||||
const finalInitiative = manualInit ? parseInt(manualInitiative, 10) : null;
|
||||
|
||||
@@ -974,25 +976,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
modifier = character.defaultInitMod || 0;
|
||||
} else {
|
||||
modifier = parseInt(monsterInitMod, 10) || 0;
|
||||
participantIsNpc = isNpc;
|
||||
participantAsNpc = asNpc;
|
||||
}
|
||||
|
||||
const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier);
|
||||
const newParticipant = {
|
||||
id: generateId(),
|
||||
name: nameToAdd,
|
||||
type: participantType,
|
||||
type: participantType === 'monster' && participantAsNpc ? 'npc' : participantType,
|
||||
originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
|
||||
initiative: computedInitiative,
|
||||
maxHp: currentMaxHp,
|
||||
currentHp: currentMaxHp,
|
||||
isNpc: participantType === 'monster' ? participantIsNpc : false,
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
deathSaves: 0,
|
||||
deathFails: 0,
|
||||
isStable: false,
|
||||
isDying: false,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0,
|
||||
deathSaveFailures: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1003,7 +1003,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
roll: manualInit ? null : initiativeRoll,
|
||||
mod: manualInit ? null : modifier,
|
||||
total: computedInitiative,
|
||||
type: participantIsNpc ? 'NPC' : participantType,
|
||||
type: participantAsNpc ? 'NPC' : participantType,
|
||||
manual: manualInit,
|
||||
});
|
||||
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
|
||||
@@ -1012,7 +1012,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
setSelectedCharacterId('');
|
||||
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
||||
setIsNpc(false);
|
||||
setAsNpc(false);
|
||||
setManualInitiative('');
|
||||
} catch (err) {
|
||||
showToast("Failed to add participant. Please try again."); }
|
||||
@@ -1041,11 +1041,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
currentHp: char.defaultMaxHp || DEFAULT_MAX_HP,
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
isNpc: false,
|
||||
deathSaves: 0,
|
||||
deathFails: 0,
|
||||
isStable: false,
|
||||
isDying: false,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0,
|
||||
deathSaveFailures: 0,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1124,18 +1122,34 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeathSaveChange = async (participantId, type, saveNumber) => {
|
||||
const handleDeathSaveChange = async (participantId, outcome) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
const { isDying } = await combatDeathSave(encounter, participantId, type, saveNumber, buildCtx(encounterPath));
|
||||
if (isDying) {
|
||||
setTimeout(async () => {
|
||||
const finalParticipants = encounter.participants.filter(p => p.id !== participantId);
|
||||
await storage.updateDoc(encounterPath, { participants: finalParticipants });
|
||||
}, 2000);
|
||||
}
|
||||
await combatDeathSave(encounter, participantId, outcome, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
// fall through silently
|
||||
showToast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNat20 = async (participantId) => handleDeathSaveChange(participantId, 'nat20');
|
||||
|
||||
const handleNat1 = async (participantId) => handleDeathSaveChange(participantId, 'nat1');
|
||||
|
||||
const handleStabilize = async (participantId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await stabilizeParticipant(encounter, participantId, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
showToast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevive = async (participantId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await reviveParticipant(encounter, participantId, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
showToast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1246,7 +1260,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
onChange={(e) => {
|
||||
setParticipantType(e.target.value);
|
||||
setSelectedCharacterId('');
|
||||
setIsNpc(false);
|
||||
setAsNpc(false);
|
||||
}}
|
||||
className="mt-1 w-full px-3 py-2 bg-stone-700 border-stone-600 rounded text-white"
|
||||
>
|
||||
@@ -1310,12 +1324,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
<div className="md:col-span-2 flex items-center pt-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isNpc"
|
||||
checked={isNpc}
|
||||
onChange={(e) => setIsNpc(e.target.checked)}
|
||||
id="asNpc"
|
||||
checked={asNpc}
|
||||
onChange={(e) => setAsNpc(e.target.checked)}
|
||||
className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500"
|
||||
/>
|
||||
<label htmlFor="isNpc" className="ml-2 block text-sm text-stone-300">
|
||||
<label htmlFor="asNpc" className="ml-2 block text-sm text-stone-300">
|
||||
Is NPC?
|
||||
</label>
|
||||
</div>
|
||||
@@ -1377,8 +1391,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
{lastRollDetails && (
|
||||
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
|
||||
{lastRollDetails.manual
|
||||
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
|
||||
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
|
||||
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
|
||||
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
@@ -1389,12 +1403,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
{sortedParticipants.map((p) => {
|
||||
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
||||
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
|
||||
const participantDisplayType = p.type === 'monster' ? (p.isNpc ? 'NPC' : 'Monster') : 'Character';
|
||||
const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character');
|
||||
const hasDeathSaves = p.type === 'character' || p.type === 'npc';
|
||||
|
||||
let bgColor = p.type === 'character' ? 'bg-indigo-950' : (p.isNpc ? 'bg-stone-700' : 'bg-[#8e351c]');
|
||||
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
|
||||
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
|
||||
|
||||
const isDead = p.currentHp === 0;
|
||||
const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious');
|
||||
const isZeroHp = p.currentHp === 0;
|
||||
const isDead = participantStatus === 'dead';
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -1404,7 +1421,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
onDragOver={isDraggable ? handleDragOver : undefined}
|
||||
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
|
||||
onDragEnd={() => setDraggedItemId(null)}
|
||||
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive && !isDead ? 'opacity-50' : ''} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
|
||||
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
|
||||
>
|
||||
<div className="flex-1 flex items-start sm:items-center">
|
||||
{isDraggable && (
|
||||
@@ -1416,14 +1433,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-lg text-white">
|
||||
{isDead && <span className="mr-2">☠️</span>}
|
||||
{isZeroHp && <span className="mr-2">☠️</span>}
|
||||
{p.name} <span className="text-xs">({participantDisplayType})</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
|
||||
</span>
|
||||
)}
|
||||
{isDead && <span className="ml-2 text-xs text-red-300 font-semibold">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
|
||||
{hasDeathSaves && participantStatus === 'dying' && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Dying)</span>}
|
||||
{hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && <span className="ml-2 text-xs text-emerald-300 font-semibold">(Unconscious)</span>}
|
||||
{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">
|
||||
@@ -1447,41 +1466,60 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
<span>HP: {p.currentHp}/{p.maxHp}</span>
|
||||
</div>
|
||||
|
||||
{/* Death saves - D&D 5e: successes + fails separate */}
|
||||
{isDead && encounter.isStarted && p.type === 'character' && (
|
||||
{participantStatus === 'dead' && (
|
||||
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive to 0 HP, stable and unconscious">
|
||||
<HeartPulse size={14} /> Revive
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Death saves - D&D 5e status state machine */}
|
||||
{hasDeathSaves && encounter.isStarted && participantStatus !== 'conscious' && (
|
||||
<div className="mt-2 flex flex-col space-y-1">
|
||||
{p.isStable && (
|
||||
<div className="text-xs text-emerald-300 font-medium">Stabilized</div>
|
||||
{participantStatus === 'dying' && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-emerald-300 font-medium w-10">Saves:</span>
|
||||
{[1, 2, 3].map(saveNum => (
|
||||
<span
|
||||
key={saveNum}
|
||||
className={`w-6 h-6 rounded border-2 flex items-center justify-center ${(p.deathSaveSuccesses || 0) >= saveNum ? 'bg-emerald-600 border-emerald-500' : 'bg-stone-800 border-stone-600'}`}
|
||||
title={`Success pip ${saveNum}`}
|
||||
>
|
||||
{(p.deathSaveSuccesses || 0) >= saveNum && <span className="text-white text-sm">✓</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-red-300 font-medium w-10">Fails:</span>
|
||||
{[1, 2, 3].map(failNum => (
|
||||
<span
|
||||
key={failNum}
|
||||
className={`w-6 h-6 rounded border-2 flex items-center justify-center ${(p.deathSaveFailures || 0) >= failNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600'}`}
|
||||
title={`Fail pip ${failNum}`}
|
||||
>
|
||||
{(p.deathSaveFailures || 0) >= failNum && <span className="text-white text-sm">✕</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button onClick={() => handleDeathSaveChange(p.id, 'success')} className="px-2 py-1 text-xs rounded bg-emerald-700 hover:bg-emerald-600 text-white" title="Death save success">
|
||||
Success
|
||||
</button>
|
||||
<button onClick={() => handleDeathSaveChange(p.id, 'fail')} className="px-2 py-1 text-xs rounded bg-red-700 hover:bg-red-600 text-white" title="Death save failure">
|
||||
Fail
|
||||
</button>
|
||||
<button onClick={() => handleNat1(p.id)} className="px-2 py-1 text-xs rounded bg-red-800 hover:bg-red-700 text-white" title="Natural 1: +2 failures">
|
||||
<AlertTriangle size={12} /> Nat1
|
||||
</button>
|
||||
<button onClick={() => handleNat20(p.id)} className="px-2 py-1 text-xs rounded bg-yellow-600 hover:bg-yellow-500 text-white" title="Natural 20: 1 HP + conscious">
|
||||
<Zap size={12} /> Nat20
|
||||
</button>
|
||||
<button onClick={() => handleStabilize(p.id)} className="px-2 py-1 text-xs rounded bg-emerald-700 hover:bg-emerald-600 text-white" title="Stabilize at 0 HP">
|
||||
<HeartPulse size={12} /> Stabilize
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{p.isDying && !p.isStable && (
|
||||
<div className="text-xs text-red-400 font-medium animate-pulse">Dying</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-emerald-300 font-medium w-10">Saves:</span>
|
||||
{[1, 2, 3].map(saveNum => (
|
||||
<button
|
||||
key={saveNum}
|
||||
onClick={() => handleDeathSaveChange(p.id, 'success', saveNum)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathSaves || 0) >= saveNum ? 'bg-emerald-600 border-emerald-500' : 'bg-stone-800 border-stone-600 hover:border-emerald-400'}`}
|
||||
title={`Success ${saveNum}`}
|
||||
>
|
||||
{(p.deathSaves || 0) >= saveNum && <span className="text-white text-sm">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-red-300 font-medium w-10">Fails:</span>
|
||||
{[1, 2, 3].map(failNum => (
|
||||
<button
|
||||
key={failNum}
|
||||
onClick={() => handleDeathSaveChange(p.id, 'fail', failNum)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathFails || 0) >= failNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600 hover:border-red-400'} ${failNum === 3 && (p.deathFails || 0) >= 3 ? 'animate-pulse' : ''}`}
|
||||
title={`Fail ${failNum}`}
|
||||
>
|
||||
{(p.deathFails || 0) >= failNum && <span className="text-white text-sm">✕</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1588,15 +1626,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isDead && (
|
||||
<button
|
||||
onClick={() => toggleParticipantActive(p.id)}
|
||||
className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300'} bg-stone-700 hover:bg-stone-600`}
|
||||
title={p.isActive ? "Mark Inactive" : "Mark Active"}
|
||||
>
|
||||
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => toggleParticipantActive(p.id)}
|
||||
className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300'} bg-stone-700 hover:bg-stone-600`}
|
||||
title={p.isActive ? "Mark Inactive" : "Mark Active"}
|
||||
>
|
||||
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenConditionsId(openConditionsId === p.id ? null : p.id)}
|
||||
className={`p-1 rounded transition-colors bg-stone-700 hover:bg-stone-600 ${openConditionsId === p.id || (p.conditions || []).length > 0 ? 'text-purple-400 hover:text-purple-300' : 'text-stone-400 hover:text-stone-300'}`}
|
||||
@@ -2160,7 +2196,15 @@ function AdminView({ userId }) {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
const [campaignsCollapsed, setCampaignsCollapsed] = useState(false);
|
||||
const [campaignsCollapsed, setCampaignsCollapsed] = useState(() => {
|
||||
try { return localStorage.getItem('ttrpg.campaignsCollapsed') === 'true'; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('ttrpg.campaignsCollapsed', String(campaignsCollapsed)); }
|
||||
catch {}
|
||||
}, [campaignsCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (campaignsData && db) {
|
||||
@@ -2597,7 +2641,7 @@ function DisplayView() {
|
||||
// 1-list model: participants[] IS the display order (DM drag = source of
|
||||
// truth). Do NOT re-sort by initiative — that diverges from AdminView /
|
||||
// turnOrderIds after any cross-init drag (BUG-15).
|
||||
participantsToRender = participants.filter(p => p.isActive || p.type !== 'monster');
|
||||
participantsToRender = participants.filter(p => p.isActive !== false);
|
||||
}
|
||||
|
||||
const displayStyles = campaignBackgroundUrl
|
||||
@@ -2654,10 +2698,10 @@ function DisplayView() {
|
||||
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
{participantsToRender.map(p => {
|
||||
const isDead = p.currentHp === 0;
|
||||
const isDying = p.isDying || false;
|
||||
let participantBgColor = p.type === 'monster'
|
||||
? (p.isNpc ? 'bg-stone-800' : 'bg-[#8e351c]')
|
||||
const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious');
|
||||
const isZeroHp = p.currentHp === 0;
|
||||
let participantBgColor = p.type === 'monster' || p.type === 'npc'
|
||||
? 'bg-[#8e351c]'
|
||||
: 'bg-indigo-950';
|
||||
|
||||
const isCurrentTurn = p.id === currentTurnParticipantId && isStarted && !isPaused;
|
||||
@@ -2672,18 +2716,20 @@ function DisplayView() {
|
||||
<div
|
||||
key={p.id}
|
||||
ref={isCurrentTurn ? currentParticipantRef : null}
|
||||
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${isDead ? 'opacity-40 grayscale' : (!p.isActive ? 'opacity-40 grayscale' : '')} ${isDying ? 'animate-death-dissolve' : ''}`}
|
||||
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${status === 'dead' ? 'opacity-40 grayscale' : (!p.isActive ? 'opacity-40 grayscale' : '')}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3
|
||||
className={`text-2xl md:text-3xl font-bold font-cinzel ${isCurrentTurn ? 'text-white' : (p.type === 'character' ? 'text-amber-100' : (p.isNpc ? 'text-stone-100' : 'text-white'))}`}
|
||||
className={`text-2xl md:text-3xl font-bold font-cinzel ${isCurrentTurn ? 'text-white' : (p.type === 'character' ? 'text-amber-100' : 'text-white')}`}
|
||||
>
|
||||
{isDead && <span className="mr-2">☠️</span>}
|
||||
{isZeroHp && <span className="mr-2">☠️</span>}
|
||||
{p.name}
|
||||
{isCurrentTurn && (
|
||||
<span className="text-yellow-300 animate-pulse ml-2">(Current)</span>
|
||||
)}
|
||||
{isDead && <span className="text-red-300 text-lg ml-2">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
|
||||
{status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>}
|
||||
{status === 'stable' && (p.conditions || []).includes('unconscious') && <span className="text-emerald-300 text-lg ml-2">(Unconscious)</span>}
|
||||
{status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>}
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xl md:text-2xl font-semibold ${isCurrentTurn ? 'text-green-200' : 'text-stone-200'}`}
|
||||
@@ -2696,7 +2742,7 @@ function DisplayView() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${isDead ? 'bg-red-900' : (p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500'))}`}
|
||||
className={`h-full rounded-full transition-all ${isZeroHp ? 'bg-red-900' : (p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500'))}`}
|
||||
style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}
|
||||
></div>
|
||||
{p.type !== 'monster' && (
|
||||
@@ -2731,7 +2777,7 @@ function DisplayView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!p.isActive && !isDead && (
|
||||
{!p.isActive && !isZeroHp && (
|
||||
<p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user